diff --git a/Cargo.lock b/Cargo.lock index fb6e1cde155..d31d0b9da19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5377,6 +5377,8 @@ dependencies = [ "percent-encoding", "rand 0.10.3", "reqwest", + "serde", + "serde_json", "sha2 0.11.1", "thiserror 2.0.21", "tokio", diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 52c40a01e67..e35cf678ded 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -154,6 +154,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. +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 The `fmp4`, `mkv`, `flv`, `h264`, and `h265` exports pick renditions with @@ -360,7 +366,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 8b2ee0f5f1d..b833fa8bc04 100644 --- a/doc/bin/hls.md +++ b/doc/bin/hls.md @@ -10,7 +10,9 @@ that can't speak MoQ, records it as HLS files, or both. `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' # Record into ./recording, ending once the broadcast finishes @@ -40,20 +42,21 @@ requested bytes fails the segment. ## 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. +- **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 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 @@ -65,8 +68,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. ## Record @@ -78,11 +81,11 @@ 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 -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 47a078ef57a..53eb3b4b07c 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -70,8 +70,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. 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/doc/setup/upgrade.md b/doc/setup/upgrade.md index 9e46ec68334..b90b5591a28 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -61,11 +61,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 with the epoch diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index d86edea5db1..4f47a853c4b 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,102 @@ 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 `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 `replayEpoch` field, if present, names the publisher instance of `replay`, with the meaning and rules of a rendition's `epoch` ({{field-epoch}}). +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 `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. +A consumer that cannot decode a rendition's `hls` field MUST treat it as absent without rejecting the rendition. -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 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. +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. 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, } ~~~ -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 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. -## 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. +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} @@ -1208,16 +1144,14 @@ This document has no IANA actions. ## moq-hang-04 {:numbered="false"} -- 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. +- 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. -- 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. @@ -1228,22 +1162,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/MIGRATE.md b/js/hang/MIGRATE.md index 10b6d7e2cce..08015b3fd40 100644 --- a/js/hang/MIGRATE.md +++ b/js/hang/MIGRATE.md @@ -9,11 +9,12 @@ How to update your code for each breaking change, newest first. `Catalog.MAX_RENDITIONS` and `Catalog.TooManyRenditions` are gone: `checkRenditions`, `encode`, and `watch` accept any number of renditions. Delete `instanceof TooManyRenditions` checks. An application that wants a limit counts the `renditions` of each section itself. -### 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 @@ -38,16 +39,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/api/catalog.api.md b/js/hang/api/catalog.api.md index da955f7e1fb..37334e98fd3 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; @@ -547,6 +563,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">; @@ -649,6 +669,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: { @@ -730,6 +754,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: { @@ -820,6 +848,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: { @@ -928,6 +968,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; }; }; }, { @@ -963,19 +1007,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>; @@ -1286,6 +1323,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">; @@ -1482,6 +1527,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: { @@ -1563,6 +1612,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: { @@ -1653,6 +1706,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 7c3b28e0ce3..fdfb6c37acc 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 { @@ -269,6 +273,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 2af6a1b91e6..4fc60800f2f 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, @@ -444,7 +448,7 @@ class Consumer_2 implements AsyncIterable { [Symbol.asyncIterator](): AsyncIterator; close(): void; next(): Promise; - static subscribe(broadcast: Net.Broadcast.Consumer, archive: Catalog.Archive, track: string): Consumer_2; + static subscribe(broadcast: Net.Broadcast.Consumer, hls: Catalog.Hls): Consumer_2; } interface ConsumerProps { @@ -534,7 +538,7 @@ function encodeFrame(source: Uint8Array | Source, timestamp: Micro): Uint8Array; type Entry = Omit & { pts: Micro; - duration: Micro; + duration?: Micro; }; const EpochSchema: z.ZodMiniType; @@ -623,6 +627,29 @@ 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 + } +} +export { Hls } + +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; @@ -697,11 +724,6 @@ interface Observer { path: Path.Valid; } -interface Position { - frame?: number; - group: number; -} - const PRIORITY: { readonly catalog: 100; readonly text: 90; @@ -780,11 +802,9 @@ interface ReceivedFrame extends Frame { interface Record_2 { duration?: number; - end: Position; - keyframe?: boolean; - pts?: number; + group: number; + pts: number; sequence: number; - start: Position; } type ReferenceEpoch = string & { @@ -944,6 +964,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">; @@ -1046,6 +1070,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: { @@ -1127,6 +1155,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: { @@ -1217,6 +1249,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: { @@ -1325,6 +1369,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; }; }; }, { @@ -1360,19 +1408,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>; @@ -1529,7 +1570,7 @@ const StatsSchema: z.ZodMiniObject<{ const SUFFIX = ".echo"; -const SUFFIX_2 = ".timeline.z"; +const SUFFIX_2 = ".hls"; type Text_2 = z_2.infer; @@ -1622,18 +1663,6 @@ interface Thresholds { }; } -declare namespace Timeline { - export { - Position, - Record_2 as Record, - SUFFIX_2 as SUFFIX, - Entry, - Event_2 as Event, - Consumer_2 as Consumer - } -} -export { Timeline } - const TRACK = "catalog.json"; type Track = z.infer; @@ -1814,6 +1843,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">; @@ -2010,6 +2047,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: { @@ -2091,6 +2132,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: { @@ -2181,6 +2226,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/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..775e82412e8 --- /dev/null +++ b/js/hang/src/catalog/hls.ts @@ -0,0 +1,23 @@ +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. 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 + // 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 f79669c9fe3..916404062fd 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 c4284007ce6..00000000000 --- a/js/hang/src/timeline.ts +++ /dev/null @@ -1,129 +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 { - /** The group sequence. */ - 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; - /** The presentation timestamp of the span's first frame. */ - pts?: number; - /** How long the span plays. */ - duration?: number; - /** The span's first frame. */ - start: Position; - /** The position just past the span's last frame. */ - 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 eba86c17ded..504e48c0079 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -154,6 +154,7 @@ 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 opt-in for gateways](/quest/m1/hls-opt-in-gateways.md) - SRT, RTMP, WebRTC ingest and the bindings can publish HLS records - [HLS and DASH segment conformance](/quest/m1/hls-conformance.md) - nightly ffprobe checks that advertised timing, sync flags, and bytes match the media served - [App-limited BBR3 estimate ages](/quest/m1/bbr-app-limited.md) - an app-limited BBR3 estimate falls within seconds after the path drops - [Aborted epoch reads keep their cause](/quest/m1/epoch-abort-cause.md) - an aborted read through an epoch route ends with its own error, not NotFound diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 17977c51806..6f0a1ee43f0 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. Kept public though only moq-transport uses them (decided 2026-10-23), so an audit skips them: diff --git a/quest/m1/archive/README.md b/quest/m1/archive/README.md index 4302334a932..9b9d927f841 100644 --- a/quest/m1/archive/README.md +++ b/quest/m1/archive/README.md @@ -69,9 +69,9 @@ 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 - ([Catalog HLS field](/quest/m1/archive/catalog-hls.md)). The root `archive` + 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. 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..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](/quest/m1/archive/catalog-hls.md) (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 @@ -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..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 @@ -16,20 +16,22 @@ 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)). - 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. ## 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-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/m2/js-hls.md b/quest/m2/js-hls.md index 405d4db516e..b0244dd3cdc 100644 --- a/quest/m2/js-hls.md +++ b/quest/m2/js-hls.md @@ -10,19 +10,14 @@ 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. -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 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 255bb4491b0..fd5d9e0ee7d 100644 --- a/rs/hang/MIGRATE.md +++ b/rs/hang/MIGRATE.md @@ -13,17 +13,20 @@ How to update your code for each breaking change, newest first. `catalog::MAX_RENDITIONS` and `Error::TooManyRenditions` are gone: a catalog with any number of renditions is accepted. Delete matches on `TooManyRenditions`. An application that wants a limit counts `video.renditions`, `audio.renditions`, and `text.renditions` itself. +### 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. @@ -31,7 +34,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) @@ -53,18 +56,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/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 affa595fa75..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::Relative::new("./recordings/clip").unwrap()); - 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 73f1ad250dc..0c13373888d 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..6361d590e5f --- /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. 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)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct Hls { + /// 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 + /// 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 b6543cef841..0c90a55b35a 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -8,9 +8,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. @@ -39,18 +38,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. @@ -123,10 +115,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, @@ -187,7 +175,6 @@ impl From> for Catalog { version: _, video, audio, - archive, clock, text, json, @@ -200,7 +187,6 @@ impl From> for Catalog { Self { video, audio, - archive, clock, text, json, @@ -319,7 +305,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() @@ -489,8 +474,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(); @@ -505,7 +489,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. @@ -690,6 +673,7 @@ mod test { warmup: None, jitter: Some(std::time::Duration::from_millis(100)), delay: Some(std::time::Duration::from_millis(200)), + hls: None, }, ); @@ -712,6 +696,7 @@ mod test { warmup: None, jitter: Some(std::time::Duration::from_millis(40)), delay: None, + hls: None, }, ); @@ -1071,97 +1056,71 @@ 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()) + .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::Relative::new("recordings/clip").unwrap()); - 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 c894813bebe..758a8d2bff7 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..2012fa78c89 --- /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`](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. +//! +//! 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..4e534acc14c 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 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: //! - **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 841d530f75c..6fdbfcf16e6 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -2109,7 +2109,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()); @@ -2395,10 +2396,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( @@ -2407,50 +2408,51 @@ 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") + // 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 first 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; + 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 + 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..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_timeline_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 1e67a2201ef..248a2dd3493 100644 --- a/rs/moq-c/src/video.rs +++ b/rs/moq-c/src/video.rs @@ -648,7 +648,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 (lower, priority) = match lower { Some(lower) => { @@ -1281,9 +1281,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_timeline_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 6571f46ad7f..21c4b168e19 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`); renditions' segments line up wherever their keyframes share timestamps. + ### `--broadcast` refuses empty segments ([#6648](https://github.com/moq-dev/moq/pull/6648)) A broadcast name or prefix with a leading, trailing, or doubled `/` is an error instead of being collapsed: pass `--broadcast room/cam`, not `/room/cam/`. diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index c58e2b6a519..349f8ace172 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -357,6 +357,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() @@ -789,8 +795,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, @@ -808,6 +814,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)] @@ -912,6 +924,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 862474dcc90..b078c65e392 100644 --- a/rs/moq-cli/src/hls.rs +++ b/rs/moq-cli/src/hls.rs @@ -168,13 +168,15 @@ pub async fn import(target: ImportTarget, args: ImportArgs, epoch: moq_net::Epoc name, max_age, linger, + hls, bandwidth, } = target; let 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.broadcast().clone(), 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 @@ -499,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"; @@ -514,6 +516,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/hls/record.rs b/rs/moq-cli/src/hls/record.rs index 466ac3da762..52ddcbcc8df 100644 --- a/rs/moq-cli/src/hls/record.rs +++ b/rs/moq-cli/src/hls/record.rs @@ -3,9 +3,10 @@ //! 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, 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 +32,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, } @@ -52,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. @@ -85,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. @@ -93,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, } @@ -109,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 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. + /// 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 }) } @@ -183,7 +189,6 @@ impl Recording { Ok(Self { dir, slots: BTreeMap::new(), - transitions: BTreeSet::new(), master: None, }) } @@ -214,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) => { @@ -238,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,16 +281,11 @@ 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 number = slot.playlist.next(segment.segment); let mut map = None; if let Some(media) = &segment.media { if instance.init.is_none() { @@ -294,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, }); @@ -316,7 +319,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) { @@ -373,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, }); @@ -400,6 +404,11 @@ struct Playlist { target: u64, /// What the file holds: its header and how many entries. written: Option<(Header, usize)>, + /// 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)] @@ -411,43 +420,43 @@ struct Header { } struct Entry { + /// The recording's number for the segment, which names its file. 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 offset. 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 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 { + /// 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) } -} -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); + 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); } - /// 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.switching = true; } - 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 +464,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 +481,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,21 +490,26 @@ 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"; } + 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"; } @@ -576,10 +590,10 @@ mod tests { fn new(names: &[&str]) -> Self { let origin = moq_tokio::origin::spawn(); let broadcast = origin.create_broadcast("live").unwrap(); - // A hinted timeline is advertised before any media, so every rendition is recorded + // Hinted records are advertised before any media, so every rendition is recorded // from its first segment. - let hinted = moq_mux::timeline::Config::default().with_duration_hint(Duration::from_secs(2)); - let config = moq_mux::catalog::Config::default().with_timeline(hinted); + let hinted = moq_mux::hls::Config::default().with_duration_hint(Duration::from_secs(2)); + let config = moq_mux::catalog::Config::default().with_hls(hinted); let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), config).unwrap(); let reserved = catalog.reserve(); let video = names @@ -684,6 +698,22 @@ mod tests { .unwrap_or_else(|_| panic!("{name} never recorded a segment: {:?}", walk(dir))); } + /// The segment numbers a media playlist lists as `EXT-X-GAP`. + fn gaps(playlist: &str) -> Vec { + let mut gap = false; + let mut gaps = Vec::new(); + for line in playlist.lines() { + if line == "#EXT-X-GAP" { + gap = true; + } else if let Some(segment) = line.strip_suffix(".m4s") + && std::mem::take(&mut gap) + { + gaps.push(segment.parse().unwrap()); + } + } + gaps + } + fn consecutive(segments: &[u64], first: u64) -> bool { segments.iter().copied().eq(first..first + segments.len() as u64) } @@ -732,12 +762,22 @@ 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}"); assert!(!playlist.contains("DISCONTINUITY"), "{playlist}"); - let files = segments.iter().map(|segment| format!("{segment}.m4s")); + // The finish's stop is listed as a gap, with no file. + let gaps = gaps(&playlist); + assert_eq!( + gaps, + segments.last().copied().into_iter().collect::>(), + "{playlist}" + ); + let files = segments + .iter() + .filter(|segment| !gaps.contains(segment)) + .map(|segment| format!("{segment}.m4s")); for file in files.chain(["init.0.mp4".to_string()]) { assert!( !std::fs::read(rendition.join(&file)).unwrap().is_empty(), @@ -750,8 +790,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"]); @@ -759,7 +799,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let recording = spawn(dir.path(), &broadcaster); - // A sibling resolves a segment once its own timeline passes the segment's end. + // A sibling resolves a segment once its own records pass the segment's end. for n in 0..5 { publisher.gop(n); } @@ -789,11 +829,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}"); @@ -807,7 +843,7 @@ mod tests { let broadcaster = publisher.broadcaster(); let dir = tempfile::tempdir().unwrap(); let recording = spawn(dir.path(), &broadcaster); - // A sibling resolves a segment once its own timeline passes the segment's end. + // A sibling resolves a segment once its own records pass the segment's end. for n in 0..5 { publisher.gop(n); } @@ -822,6 +858,7 @@ mod tests { }) .unwrap(); publisher.gop(5); + publisher.gop(6); moq_clock::timeout(TIMEOUT, async { while removed.strong_count() > 0 { tokio::task::yield_now().await; @@ -830,7 +867,7 @@ mod tests { .await .expect("the drained rendition was released"); - publisher.gop(6); + publisher.gop(7); publisher.finish(); moq_clock::timeout(TIMEOUT, recording) .await @@ -904,7 +941,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, } @@ -927,6 +969,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. @@ -946,33 +1007,78 @@ mod tests { duration: Duration::from_secs(2), gap: false, discontinuity, + program_date_time: None, map: (segment == 0).then(|| "init.0.mp4".to_string()), } } - /// 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. + /// 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 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] - 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}" ); @@ -980,15 +1086,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-cli/src/main.rs b/rs/moq-cli/src/main.rs index eda6fb07fbb..41508062042 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -733,6 +733,7 @@ fn spawn_import( name, max_age, linger, + hls: import.hls, bandwidth: bandwidth.clone(), }; @@ -742,7 +743,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), .. @@ -809,7 +811,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 068c293f6d1..eda3a8a3fe3 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -478,9 +478,12 @@ impl Publish { args: &CaptureArgs, bandwidth: moq_net::bandwidth::Allocator, max_age: Option, + hls: bool, ) -> anyhow::Result { let mut broadcast = publication.broadcast().clone(); - 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 8039444f956..6d9a1196f3f 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 name = moq_net::Path::new(&name)?; diff --git a/rs/moq-cli/src/rtsp.rs b/rs/moq-cli/src/rtsp.rs index 2565277ca2f..3e0e12290e2 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.broadcast().clone(), 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 451a9bb3147..0454b462938 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 name = moq_net::Path::new(&name)?; diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 8cf6649cb72..28100c350b9 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-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index 3f794b297e8..a50fe16113f 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -772,7 +772,7 @@ impl MoqVideoProducer { }; Ok(moq_video::encode::Producer::with_track( track, - state.catalog.reserve().with_timeline_hint(gop), + state.catalog.reserve().with_hls_hint(gop), rendition, )?) })?; diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index a4f0a5a5020..85d2c4c6acf 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -30,6 +30,8 @@ moq-net = { workspace = true } percent-encoding = { workspace = true } rand = { workspace = true } reqwest = { workspace = true, features = ["rustls", "gzip"] } +serde = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/rs/moq-hls/MIGRATE.md b/rs/moq-hls/MIGRATE.md index cdab8a6fd34..6d96505d428 100644 --- a/rs/moq-hls/MIGRATE.md +++ b/rs/moq-hls/MIGRATE.md @@ -4,6 +4,15 @@ 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 }`. +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 `export::segments::Consumer::next` yields a segment the rendition has no media @@ -18,7 +27,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 @@ -44,14 +53,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 */ } } ``` @@ -87,7 +96,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/error.rs b/rs/moq-hls/src/error.rs index ab3c7866dde..3b77ea3c104 100644 --- a/rs/moq-hls/src/error.rs +++ b/rs/moq-hls/src/error.rs @@ -132,12 +132,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 83a7497de05..59b18280362 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,10 +229,6 @@ impl Recording { } } -fn timeline_name(track: &str) -> String { - hang::timeline::default_name(track) -} - /// One Legacy frame: a VP8 keyframe for video (geometry the muxer can parse), filler otherwise. fn legacy(track: &str, micros: u64, keyframe: bool) -> Bytes { let payload: &'static [u8] = match (track.starts_with("audio"), keyframe) { @@ -280,25 +245,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 +332,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 +374,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 = replay.broadcaster.rendition(kind, name).unwrap().tag().to_string(); 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 +394,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,43 +451,60 @@ 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_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: 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. 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); + 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", - ); - 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}" - ); + 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::>() + }; + 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), 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 after = rendition.listed_segment(2).await.unwrap().unwrap(); + 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"); } @@ -542,42 +516,55 @@ 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 = replay + .broadcaster + .rendition(Kind::Video, "1080p") + .unwrap() + .tag() + .to_string(); - // 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 = replay + .broadcaster + .rendition(Kind::Video, "1080p") + .unwrap() + .tag() + .to_string(); 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 931f74ec086..9de11ac934c 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -1,19 +1,16 @@ //! 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. +//! 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. @@ -37,7 +34,6 @@ mod mpd; mod playlist; mod rendition; mod route; -mod spans; pub mod renditions; pub mod segments; @@ -51,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. /// @@ -77,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, } @@ -111,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 { @@ -127,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 } @@ -169,7 +155,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) } @@ -177,7 +163,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() } @@ -201,7 +187,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. @@ -229,9 +215,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); @@ -265,8 +250,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 @@ -280,10 +264,9 @@ impl Broadcaster { /// missing init in the background and a later render lists that rendition; no render waits /// on one. /// - /// 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); @@ -291,6 +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 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() { @@ -306,18 +293,27 @@ impl Broadcaster { // of this render. rendition.start_init(); pending |= rendition.init_pending(); - let representations = match rendition.representations() { - Ok(representations) => representations, + let representation = match rendition.representation() { + Ok(representation) => { + listable = true; + 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 (false, Some(err)) = (listable, skipped) { + return Err(err); + } let representations = || video.iter().chain(&audio); if representations().all(|rep| rep.segments.is_empty()) { return Ok(None); @@ -360,10 +356,10 @@ impl Broadcaster { self.renditions.video_init().await; } - /// 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") @@ -389,22 +385,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; @@ -426,31 +415,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"); @@ -458,116 +425,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, @@ -595,142 +455,54 @@ 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(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; - } - - 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)) - } + fn opus() -> hang::catalog::AudioConfig { + hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 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 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)) } fn frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { @@ -746,13 +518,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), @@ -776,22 +546,64 @@ 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").unwrap()); - 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. @@ -808,21 +620,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").unwrap()); + 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)), @@ -834,14 +664,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() @@ -860,22 +687,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 @@ -967,7 +810,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!( @@ -975,33 +818,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] @@ -1034,8 +853,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(); @@ -1051,18 +871,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"); @@ -1085,31 +898,35 @@ 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 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 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.broadcast().clone(), 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.broadcast().clone()); + 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 { @@ -1146,34 +963,83 @@ 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, - /// so a re-enable lists it again without resetting `EXT-X-MEDIA-SEQUENCE`. The master's - /// long-poll wakes for a re-enable even though the rendition set is unchanged. + // A client resolves a `.` or `..` URL segment away and the router refuses an empty one, so + // renditions with those names are never admitted, while their siblings keep serving. #[tokio::test(start_paused = true)] - async fn a_disabled_rendition_leaves_the_master_and_keeps_its_playlist() { + async fn renditions_named_outside_one_path_segment_are_refused() { + // A video and an audio rendition can't share a name, so the refused names split. + let refused = [(Kind::Video, ""), (Kind::Video, "."), (Kind::Audio, "..")]; let origin = produce_origin(); - let broadcast = origin.create_broadcast("live").expect("publish allowed"); - broadcast.announce(Default::default()).expect("publish allowed"); + let broadcast = origin.create_broadcast("live").unwrap(); + broadcast.announce(Default::default()).unwrap(); settle().await; - let catalog = - moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), 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), - ) + let mut catalog = + moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), Default::default()).unwrap(); + let all: Vec<_> = refused.into_iter().chain([(Kind::Video, "video0")]).collect(); + catalog + .mutate(|catalog| { + for &(kind, name) in &all { + match kind { + Kind::Video => { + let mut config = video_config(); + config.hls = Some(hls(name)); + catalog.video.renditions.insert(name.into(), config); + } + Kind::Audio => { + let mut config = opus(); + config.hls = Some(hls(name)); + catalog.audio.renditions.insert(name.into(), config); + } + } + } + }) .unwrap(); + let mut records: Vec<_> = all + .iter() + .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)) + }) + .collect(); + for sequence in 0..4 { + for (_, records) in &mut records { + records.push(&gop(sequence)).unwrap(); + } + } + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + tokio::time::timeout(Duration::from_secs(5), broadcaster.advertised()) + .await + .expect("video0 is advertised"); + + let master = broadcaster.master_playlist(None); + let manifest = broadcaster.manifest(None).unwrap().expect("video0 is listed"); + assert!(master.contains("/video/video0/media.m3u8"), "{master}"); + assert!(manifest.contains("id=\"video/video0\""), "{manifest}"); + for (kind, name) in refused { + assert!(broadcaster.rendition(kind, name).is_none(), "{kind:?} {name:?}"); + let segment = format!("/{}/{name}/", kind.as_str()); + assert!(!master.contains(&segment), "{master}"); + let id = format!("id=\"{}/{name}\"", kind.as_str()); + assert!(!manifest.contains(&id), "{manifest}"); + } + } + + /// A disabled rendition leaves the master playlist but keeps its window and media playlist, + /// so a re-enable lists it again without resetting `EXT-X-MEDIA-SEQUENCE`. The master's + /// long-poll wakes for a re-enable even though the rendition set is unchanged. + #[tokio::test(start_paused = true)] + async fn a_disabled_rendition_leaves_the_master_and_keeps_its_playlist() { + let origin = produce_origin(); + let broadcast = origin.create_broadcast("live").expect("publish allowed"); + broadcast.announce(Default::default()).expect("publish allowed"); + settle().await; + let catalog = hls_catalog(&mut broadcast.broadcast().clone()); + let mut media = video(&catalog, &broadcast, "video0", video_config()); for i in 0..3 { media.write(vp8_frame(i * 2_000_000, true)).unwrap(); } @@ -1196,7 +1062,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; @@ -1215,7 +1081,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") @@ -1223,357 +1089,118 @@ mod tests { assert!(listed()); assert_eq!(sequence(&rendition), before, "the media sequence did not reset"); - drop((media, registration, broadcast)); - } - - #[tokio::test(start_paused = true)] - async fn disabled_reference_handoff_continues_the_source_media_sequence() { - let origin = produce_origin(); - let broadcast = origin.create_broadcast("live").unwrap(); - broadcast.announce(Default::default()).unwrap(); - settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), 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(" (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(); } - let broadcaster = Broadcaster::new( - crate::resolved(&origin, "live").await, - Config { - window: Duration::from_secs(4), - ..Default::default() - }, - ); - broadcaster.advertised().await; - let first = broadcaster.rendition(Kind::Video, "video0").unwrap(); - let sibling = broadcaster.rendition(Kind::Video, "video1").unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { - while sibling - .snapshot() - .segments - .last() - .is_none_or(|segment| segment.segment < 1) - { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .unwrap(); - let before = sibling.snapshot(); - let content = first.listed_content(1).unwrap(); - let old_tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - let new_tag = segments::tag(&(Kind::Video, "video1".into(), "video1".into())); - catalog - .mutate(|catalog| catalog.video.renditions.get_mut("video0").unwrap().enabled = false) - .unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { - while sibling - .snapshot() - .segments - .last() - .is_none_or(|segment| segment.tag != new_tag) - { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the next replacement boundary advances beyond the unaligned tail"); - let after = sibling.snapshot(); - assert_eq!(after.media_sequence, before.media_sequence + 1); - assert_eq!(after.segments.len(), 2); - assert_eq!(after.segments[0].tag, old_tag); - assert_eq!( - after.segments[0].duration, - Duration::from_secs(1), - "the old 2s..3s URL keeps its advertised span" - ); - assert_eq!(first.listed_content(1).unwrap(), content); - assert_eq!(after.segments[1].segment, 2); - assert!(after.segments[1].discontinuity); - assert!(!after.finished); - build_inits(&broadcaster).await; - let playlist = sibling.media_playlist(None).unwrap().unwrap(); - assert_eq!(playlist.matches("#EXT-X-DISCONTINUITY\n").count(), 1, "{playlist}"); - let mpd = broadcaster.manifest(None).unwrap().unwrap(); - assert_eq!( - mpd.matches(" hang::catalog::VideoConfig { + hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8) } - // The replacement numbers its 1s records ahead of the old 2s ones: the edge that saw the - // handoff drops its advertised rows rather than renumbering the replacement's. + /// 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 edges_before_and_after_a_handoff_ahead_agree_on_the_media_sequence() { - edges_around_a_handoff(1_000).await; - } - - /// The old reference cuts 2s records through 6s; the replacement cuts `span_ms` records. - async fn edges_around_a_handoff(span_ms: u64) { + async fn a_transmuxed_segment_keeps_its_init_with_an_unfinished_tail() { let origin = produce_origin(); - let broadcast = origin.create_broadcast("live").unwrap(); - broadcast.announce(Default::default()).unwrap(); + let broadcast = origin.create_broadcast("live").expect("publish allowed"); + let (mut catalog, _media) = publish_vp8(&mut broadcast.broadcast().clone(), inline_vp8()); + broadcast.announce(moq_net::origin::Route::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"] { - catalog.video.renditions.insert(name.into(), video_config()); - } - catalog.archive = Some(archive(&["video0", "video1"])); - }) - .unwrap(); - let timeline = |name: &str| { - moq_mux::timeline::Producer::new( - broadcast - .create_track(hang::timeline::default_name(name), moq_mux::timeline::Producer::info()) - .unwrap(), - ) - }; - let mut old = timeline("video0"); - let mut new = timeline("video1"); - let record = |sequence: u64| span(sequence, sequence * span_ms, span_ms, sequence); - for sequence in 0..3 { - old.push(&gop(sequence)).unwrap(); - } - // The sibling's timeline runs past the old reference's last record, so that record resolves. - let mut next = 0; - while next * span_ms < 8_000 { - new.push(&record(next)).unwrap(); - next += 1; - } - let resumed = 6_000 / span_ms; - let config = Config { - window: Duration::from_secs(4), - ..Default::default() - }; - let old_tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - let new_tag = segments::tag(&(Kind::Video, "video1".into(), "video1".into())); - let listing = |snapshot: &playlist::Snapshot| -> Vec<(String, u64)> { - snapshot - .segments - .iter() - .map(|segment| (segment.tag.clone(), segment.segment)) - .collect() - }; + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + broadcaster.ready().await; + let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); + let mut cursor = rendition.segments(); + let segment = cursor.next().await.unwrap().expect("completed segment"); + assert!(!segment.media.expect("media, not a gap").is_empty()); - 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"); + catalog.finish().unwrap(); + while !rendition.snapshot().finished { + tokio::task::yield_now().await; } + let init = tokio::time::timeout(Duration::from_secs(1), rendition.latest_init()) + .await + .expect("init must not wait for the unfinished trailing group") + .unwrap() + .expect("transmuxed segment has an init"); + assert_eq!(&init[4..8], b"ftyp"); + } - 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); - } + /// An init bootstrap already blocked on an open group must not hold back a later request + /// once a completed segment has cached the init. + #[tokio::test(start_paused = true)] + async fn a_blocked_init_bootstrap_does_not_hide_a_cached_init() { + let (origin, broadcast, _catalog, mut records) = inline_pair("video1").await; + let media = broadcast.create_track("video1", timed()).unwrap(); + // At first only group 0 is listed, bounded by a stop while it is still open. + let mut open = media.create_group(0u64.into()).unwrap(); + write_keyframe(&mut open, 0); + for records in &mut records { + records.push(&gop(0)).unwrap(); + records.push(&stop(1, 2_000)).unwrap(); } - 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" + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + broadcaster.ready().await; + let rendition = broadcaster.rendition(Kind::Video, "video1").expect("rendition"); + until_listed(&rendition, 1).await; + let mut blocked = std::pin::pin!(rendition.latest_init()); + assert!( + tokio::time::timeout(Duration::from_millis(10), blocked.as_mut()) + .await + .is_err(), + "the bootstrap reads the open group" ); - if resumed == 3 { - build_inits(&saw).await; - build_inits(&after).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()); + + // Closed groups follow, and transmuxing one caches the init. + for group in 1..3 { + let mut writing = media.create_group(group.into()).unwrap(); + write_keyframe(&mut writing, group); + writing.finish().unwrap(); + } + for records in &mut records { + for group in 1..4 { + records + .push(&hang::hls::Record::new(group + 1, group, group * 2_000)) + .unwrap(); + } } + until_listed(&rendition, 4).await; + let segment = rendition + .numbered(rendition.tag(), 2) + .await + .unwrap() + .expect("completed segment"); + assert!(!segment.is_empty()); + tokio::time::timeout(Duration::from_secs(1), rendition.latest_init()) + .await + .expect("init must not wait behind the blocked bootstrap") + .unwrap() + .expect("transmuxed segment has an init"); + drop(open); } - /// A broadcast advertising timelines for `video0` and `video1`. - async fn timeline_pair() -> ( + /// `video0` and `video1`, each with an empty record track: `inline` is a VP8 whose init needs + /// a keyframe group, and the other's init builds from the catalog. Media is left to the test. + async fn inline_pair( + inline: &str, + ) -> ( moq_net::origin::Producer, moq_net::origin::Publication, moq_mux::catalog::Producer, + [moq_mux::hls::Producer; 2], ) { let origin = produce_origin(); let broadcast = origin.create_broadcast("live").unwrap(); @@ -1584,98 +1211,60 @@ mod tests { catalog .mutate(|catalog| { for name in ["video0", "video1"] { - catalog.video.renditions.insert(name.into(), video_config()); + let mut config = if name == inline { inline_vp8() } else { video_config() }; + config.hls = Some(hls(name)); + catalog.video.renditions.insert(name.into(), 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}"); - } - - /// Build every rendition's init, as a playlist or segment request does. - async fn build_inits(broadcaster: &Broadcaster) { - for rendition in broadcaster.renditions.snapshot() { - rendition.latest_init().await.unwrap(); - } + let records = ["video0", "video1"].map(|name| { + let track = broadcast + .create_track(hang::hls::default_name(name), moq_mux::hls::Producer::info()) + .unwrap(); + moq_mux::hls::Producer::new(track) + }); + (origin, broadcast, catalog, records) } - /// `timeline_pair`, with `video1` learning its geometry from media so its init needs a - /// keyframe group, and both timelines indexing four groups. `video1`'s last record is split - /// inside group 3, as for a group still open past the timeline's ceiling. + /// [`inline_pair`] with `video1` inline, recording four [`gop`]s on each, so `video0` lists + /// three segments. + /// `video1`'s last is bounded only by a stop, so its group may still be open, while group 2 + /// is complete. async fn inline_sibling() -> ( moq_net::origin::Producer, moq_net::origin::Publication, moq_mux::catalog::Producer, - [moq_mux::timeline::Producer; 2], + [moq_mux::hls::Producer; 2], ) { - let (origin, broadcast, mut catalog) = timeline_pair().await; - catalog - .mutate(|catalog| { - let config = catalog.video.renditions.get_mut("video1").unwrap(); - config.coded_width = None; - config.coded_height = None; - }) - .unwrap(); - let (_, mut video0) = timeline_track(&broadcast, "video0"); - let (_, mut video1) = timeline_track(&broadcast, "video1"); - for sequence in 0..4 { - video0.push(&gop(sequence)).unwrap(); + let (origin, broadcast, catalog, mut records) = inline_pair("video1").await; + for records in &mut records { + for sequence in 0..4 { + records.push(&gop(sequence)).unwrap(); + } } - for sequence in 0..3 { - video1.push(&gop(sequence)).unwrap(); + records[1].push(&stop(4, 8_000)).unwrap(); + (origin, broadcast, catalog, records) + } + + /// Write one VP8 keyframe at [`gop`] `sequence`'s pts into `group`. + fn write_keyframe(group: &mut moq_net::group::Producer, sequence: u64) { + hang::container::Frame { + timestamp: moq_net::Timestamp::from_millis(sequence * 2_000).unwrap(), + payload: bytes::Bytes::from_static(KEYFRAME), } - let split = hang::timeline::Record::new( - 3, - 6_000, - 2_000, - hang::timeline::Position::group(3), - hang::timeline::Position::new(3, 1), - ); - video1.push(&split).unwrap(); - (origin, broadcast, catalog, [video0, video1]) + .write_to(group) + .unwrap(); + } + + /// Wait until `rendition` lists at least `count` segments. + async fn until_listed(rendition: &Rendition, count: usize) { + tokio::time::timeout(Duration::from_secs(5), async { + while rendition.snapshot().segments.len() < count { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the segments are listed"); } /// Render the manifest until it lists `video1`. @@ -1688,27 +1277,16 @@ mod tests { } tokio::time::sleep(Duration::from_millis(10)).await; } - }) - .await - .expect("video1 is listed once its init is built") - } - - /// Write one VP8 keyframe at `sequence`'s [`gop`] pts into `group`. - fn write_keyframe(group: &mut moq_net::group::Producer, sequence: u64) { - let frame = vp8_frame(sequence * 2_000_000, true); - hang::container::Frame { - timestamp: frame.timestamp, - payload: frame.payload, - } - .write_to(group) - .unwrap(); + }) + .await + .expect("video1 is listed once its init is built") } - // An inline init bootstraps from the newest closed keyframe group, read from cache, rather - // than waiting on the newest group the publisher is still writing. + // An inline init bootstraps from a complete group, read from cache, rather than waiting on a + // newer group the publisher is still writing. #[tokio::test(start_paused = true)] - async fn an_init_bootstraps_from_a_closed_group() { - let (origin, broadcast, _catalog, _timelines) = inline_sibling().await; + async fn an_init_bootstraps_from_a_complete_group() { + let (origin, broadcast, _catalog, _records) = inline_sibling().await; let media = broadcast.create_track("video1", timed()).unwrap(); for sequence in 0..3 { let mut group = media.create_group(sequence.into()).unwrap(); @@ -1720,26 +1298,24 @@ mod tests { let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); broadcaster.advertised().await; - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 3).await; listed_in_manifest(&broadcaster).await; drop(open); } // A rendition whose init bootstraps from a still-open group never holds up the manifest: its - // sibling renders at once, and it joins once the group closes. Its closed groups already + // sibling renders at once, and it joins once the group closes. Its complete groups already // left the cache, so the open one is the only candidate left. #[tokio::test(start_paused = true)] async fn a_stuck_init_leaves_the_manifest_to_its_siblings() { - let (origin, broadcast, _catalog, _timelines) = inline_sibling().await; + let (origin, broadcast, _catalog, _records) = inline_sibling().await; 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 tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 3).await; tokio::time::timeout(Duration::from_millis(1), broadcaster.video_init()) .await .expect("one video init is enough to render"); @@ -1757,33 +1333,87 @@ mod tests { assert!(manifest.contains("id=\"video/video0\""), "{manifest}"); } - // A background build parked on an open group holds neither its rendition nor the export's - // timeline watcher, so dropping the broadcaster releases both. + // 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)] async fn a_stuck_init_build_never_outlives_its_rendition() { - let (origin, broadcast, _catalog, _timelines) = inline_sibling().await; + let (origin, broadcast, _catalog, _records) = inline_sibling().await; 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 tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 3).await; let stuck = broadcaster.rendition(Kind::Video, "video1").unwrap(); stuck.start_init(); tokio::time::sleep(Duration::from_secs(1)).await; assert!(stuck.init_pending(), "the build is parked on the open group"); let weak = Arc::downgrade(&stuck); drop(stuck); - let watcher = broadcaster.timeline_watcher.clone(); drop(broadcaster); + settle().await; assert!(weak.upgrade().is_none(), "the build kept its rendition alive"); - assert!( - watcher.lock().unwrap().is_none(), - "the build kept the timeline watcher running" - ); drop(open); } @@ -1791,15 +1421,14 @@ mod tests { // background init build there. #[tokio::test(start_paused = true)] async fn a_manifest_renders_off_the_runtime() { - let (origin, broadcast, _catalog, _timelines) = inline_sibling().await; + let (origin, broadcast, _catalog, _records) = inline_sibling().await; 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 tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 3).await; let render = broadcaster.clone(); std::thread::spawn(move || render.manifest(None).map(|_| ())) .join() @@ -1814,7 +1443,7 @@ mod tests { // build its own init. #[tokio::test(start_paused = true)] async fn a_new_generation_releases_a_stuck_init_build() { - let (origin, broadcast, _catalog, _timelines) = inline_sibling().await; + let (origin, broadcast, _catalog, _records) = inline_sibling().await; let media = broadcast.create_track("video1", timed()).unwrap(); let mut open = media.create_group(3u64.into()).unwrap(); write_keyframe(&mut open, 3); @@ -1822,8 +1451,7 @@ mod tests { let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); broadcaster.advertised().await; broadcaster.set_generation(Some("run1")).unwrap(); - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 3).await; let stuck = broadcaster.rendition(Kind::Video, "video1").unwrap(); stuck.start_init(); tokio::time::sleep(Duration::from_secs(1)).await; @@ -1840,7 +1468,7 @@ mod tests { // without it. #[tokio::test(start_paused = true)] async fn an_ended_manifest_stays_dynamic_while_an_init_builds() { - let (origin, broadcast, mut catalog, mut timelines) = inline_sibling().await; + let (origin, broadcast, mut catalog, mut records) = inline_sibling().await; let media = broadcast.create_track("video1", timed()).unwrap(); let mut open = media.create_group(3u64.into()).unwrap(); write_keyframe(&mut open, 3); @@ -1848,10 +1476,10 @@ mod tests { 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; - for timeline in &mut timelines { - timeline.finish().unwrap(); + until_listed(&rendition, 3).await; + records[0].push(&stop(4, 8_000)).unwrap(); + for records in &mut records { + records.finish().unwrap(); } catalog.finish().unwrap(); tokio::time::timeout(Duration::from_secs(5), async { @@ -1875,30 +1503,20 @@ mod tests { // with no listed segment has at once, and not only until the catalog closes. #[tokio::test(start_paused = true)] async fn the_first_video_wait_needs_a_listable_video() { - let (origin, broadcast, mut catalog) = timeline_pair().await; - catalog - .mutate(|catalog| { - let config = catalog.video.renditions.get_mut("video0").unwrap(); - config.coded_width = None; - config.coded_height = None; - }) - .unwrap(); - // The inline reference indexes media; video1's init builds from the catalog alone, but - // its timeline resolves no segment. - let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); - let (_video1_track, _video1) = timeline_track(&broadcast, "video1"); + let (origin, broadcast, mut catalog, mut records) = inline_pair("video0").await; + // The inline `video0` lists segments; `video1`'s init builds from the catalog alone, but + // it records nothing. for sequence in 0..3 { - video0.push(&gop(sequence)).unwrap(); + records[0].push(&gop(sequence)).unwrap(); } let media = broadcast.create_track("video0", timed()).unwrap(); - // Its earlier groups already left the cache, so the open one is the only candidate. - let mut open = media.create_group(2u64.into()).unwrap(); - write_keyframe(&mut open, 2); + // Its earlier group already left the cache, so the open one is the only candidate. + let mut open = media.create_group(1u64.into()).unwrap(); + write_keyframe(&mut open, 1); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 1).await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 2).await; catalog.finish().unwrap(); settle().await; @@ -1909,345 +1527,74 @@ mod tests { .is_err(), "resolved without a listable video" ); - open.finish().unwrap(); - tokio::time::timeout(Duration::from_secs(1), waiting) - .await - .expect("video0 is listable once its init is built"); - } - - // An inline video with nothing indexed when the wait starts has nothing to build from yet. - // The wait starts its build once the timeline arrives, rather than timing out without video. - #[tokio::test(start_paused = true)] - async fn the_first_video_wait_builds_an_init_indexed_while_waiting() { - let (origin, broadcast, mut catalog) = timeline_pair().await; - catalog - .mutate(|catalog| { - let config = catalog.video.renditions.get_mut("video0").unwrap(); - config.coded_width = None; - config.coded_height = None; - }) - .unwrap(); - // video1's init builds from the catalog alone, but its timeline resolves no segment. - let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); - let (_video1_track, _video1) = timeline_track(&broadcast, "video1"); - let media = broadcast.create_track("video0", timed()).unwrap(); - - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); - while broadcaster.rendition(Kind::Video, "video0").is_none() { - moq_clock::sleep(Duration::from_millis(10)).await; - } - let mut waiting = std::pin::pin!(broadcaster.video_init()); - assert!( - moq_clock::timeout(Duration::from_secs(1), waiting.as_mut()) - .await - .is_err(), - "resolved without a listable video" - ); - - for sequence in 0..3 { - let mut group = media.create_group(sequence.into()).unwrap(); - write_keyframe(&mut group, sequence); - group.finish().unwrap(); - video0.push(&gop(sequence)).unwrap(); - } - moq_clock::timeout(Duration::from_secs(1), waiting) - .await - .expect("the wait builds video0's init once its timeline arrives"); - } - - // The manifest leaves a disabled rendition out, so it never fetches media for its init. - #[tokio::test(start_paused = true)] - async fn a_disabled_rendition_never_builds_its_init() { - let (origin, broadcast, mut catalog, _timelines) = inline_sibling().await; - catalog - .mutate(|catalog| catalog.video.renditions.get_mut("video1").unwrap().enabled = false) - .unwrap(); - let media = broadcast.create_track("video1", timed()).unwrap(); - for sequence in 0..4 { - let mut group = media.create_group(sequence.into()).unwrap(); - write_keyframe(&mut group, sequence); - group.finish().unwrap(); - } - - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); - broadcaster.advertised().await; - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; - for _ in 0..3 { - assert!(broadcaster.manifest(None).unwrap().is_some()); - tokio::time::sleep(Duration::from_secs(1)).await; - } - let disabled = broadcaster.rendition(Kind::Video, "video1").unwrap(); - assert!(!disabled.snapshot().segments.is_empty()); - assert!( - disabled.media_playlist(None).unwrap().is_none(), - "no init was built for the disabled rendition" - ); - - catalog - .mutate(|catalog| catalog.video.renditions.get_mut("video1").unwrap().enabled = true) - .unwrap(); - listed_in_manifest(&broadcaster).await; - } - - // A client resolves a `.` or `..` URL segment away and the router refuses an empty one, so - // renditions with those names are never admitted, while their siblings keep serving. - #[tokio::test(start_paused = true)] - async fn renditions_named_outside_one_path_segment_are_refused() { - // A video and an audio rendition can't share a name, so the refused names split. - let refused = [(Kind::Video, ""), (Kind::Video, "."), (Kind::Audio, "..")]; - let tracks = ["", ".", "..", "video0"]; - 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 (kind, name) in refused.into_iter().chain([(Kind::Video, "video0")]) { - match kind { - Kind::Video => { - catalog.video.renditions.insert(name.into(), video_config()); - } - Kind::Audio => { - let config = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); - catalog.audio.renditions.insert(name.into(), config); - } - } - } - catalog.archive = Some(archive(&tracks)); - }) - .unwrap(); - let mut timelines: Vec<_> = tracks - .into_iter() - .map(|name| timeline_track(&broadcast, name)) - .collect(); - for sequence in 0..4 { - for (_, timeline) in &mut timelines { - timeline.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, "".into(), "".into())); - listed_under(&healthy, &tag, 2).await; - - let master = broadcaster.master_playlist(None); - let manifest = broadcaster.manifest(None).unwrap().expect("video0 is listed"); - assert!(master.contains("/video/video0/media.m3u8"), "{master}"); - assert!(manifest.contains("id=\"video/video0\""), "{manifest}"); - for (kind, name) in refused { - assert!(broadcaster.rendition(kind, name).is_none(), "{kind:?} {name:?}"); - let segment = format!("/{}/{name}/", kind.as_str()); - assert!(!master.contains(&segment), "{master}"); - let id = format!("id=\"{}/{name}\"", kind.as_str()); - assert!(!manifest.contains(&id), "{manifest}"); - } - } - - // 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 broadcast = origin.create_broadcast("live").expect("publish allowed"); - let (mut catalog, mut registration, _media) = publish_vp8(&mut broadcast.broadcast().clone()); - registration - .set(hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8)) - .unwrap(); - broadcast.announce(moq_net::origin::Route::default()).unwrap(); - settle().await; - let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); - broadcaster.ready().await; - let rendition = broadcaster.rendition(Kind::Video, "video0").expect("rendition"); - let mut cursor = rendition.segments(); - let segment = cursor.next().await.unwrap().expect("completed segment"); - assert!(!segment.media.expect("media, not a gap").is_empty()); - - catalog.finish().unwrap(); - while !rendition.snapshot().finished { - tokio::task::yield_now().await; - } - let init = tokio::time::timeout(Duration::from_secs(1), rendition.latest_init()) - .await - .expect("init must not wait for the unfinished trailing group") - .unwrap() - .expect("transmuxed segment has an init"); - assert_eq!(&init[4..8], b"ftyp"); + open.finish().unwrap(); + tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("video0 is listable once its init is built"); } - /// An init bootstrap already blocked on an open group must not hold back a later request - /// once a completed segment has cached the init. + // An inline video with nothing listed when the wait starts has nothing to build from yet. + // The wait starts its build once its records arrive, rather than timing out without video. #[tokio::test(start_paused = true)] - async fn a_blocked_init_bootstrap_does_not_hide_a_cached_init() { - let (origin, broadcast, mut catalog) = timeline_pair().await; - catalog - .mutate(|catalog| { - let config = catalog.video.renditions.get_mut("video1").unwrap(); - config.coded_width = None; - config.coded_height = None; - }) - .unwrap(); - let (_video0_track, mut video0) = timeline_track(&broadcast, "video0"); - let (_video1_track, mut video1) = timeline_track(&broadcast, "video1"); - let media = broadcast.create_track("video1", timed()).unwrap(); - // At first only the open group 0 is indexed, split before it finished. - let mut open = media.create_group(0u64.into()).unwrap(); - write_keyframe(&mut open, 0); - video0.push(&gop(0)).unwrap(); - let split = hang::timeline::Record::new( - 0, - 0, - 2_000, - hang::timeline::Position::group(0), - hang::timeline::Position::new(0, 1), - ); - video1.push(&split).unwrap(); + async fn the_first_video_wait_builds_an_init_listed_while_waiting() { + let (origin, broadcast, _catalog, mut records) = inline_pair("video0").await; + let media = broadcast.create_track("video0", timed()).unwrap(); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); - broadcaster.ready().await; - tokio::time::sleep(Duration::from_millis(100)).await; - let rendition = broadcaster.rendition(Kind::Video, "video1").expect("rendition"); - let mut blocked = std::pin::pin!(rendition.latest_init()); + while broadcaster.rendition(Kind::Video, "video0").is_none() { + moq_clock::sleep(Duration::from_millis(10)).await; + } + let mut waiting = std::pin::pin!(broadcaster.video_init()); assert!( - tokio::time::timeout(Duration::from_millis(10), blocked.as_mut()) + moq_clock::timeout(Duration::from_secs(1), waiting.as_mut()) .await .is_err(), - "the bootstrap reads the open group" + "resolved without a listable video" ); - // Closed groups follow, and transmuxing one caches the init. - for sequence in 1..3 { + for sequence in 0..3 { let mut group = media.create_group(sequence.into()).unwrap(); write_keyframe(&mut group, sequence); group.finish().unwrap(); - video1.push(&gop(sequence)).unwrap(); - } - for sequence in 1..4 { - video0.push(&gop(sequence)).unwrap(); + records[0].push(&gop(sequence)).unwrap(); } - let tag = segments::tag(&(Kind::Video, "video0".into(), "video0".into())); - listed_under(&broadcaster.rendition(Kind::Video, "video0").unwrap(), &tag, 3).await; - let segment = rendition.numbered(&tag, 1).await.unwrap().expect("completed segment"); - assert!(!segment.is_empty()); - tokio::time::timeout(Duration::from_secs(1), rendition.latest_init()) + moq_clock::timeout(Duration::from_secs(1), waiting) .await - .expect("init must not wait behind the blocked bootstrap") - .unwrap() - .expect("transmuxed segment has an init"); - drop(open); + .expect("the wait builds video0's init once its records arrive"); + } + + // The manifest leaves a disabled rendition out, so it never fetches media for its init. + #[tokio::test(start_paused = true)] + async fn a_disabled_rendition_never_builds_its_init() { + let (origin, broadcast, mut catalog, _records) = inline_sibling().await; + catalog + .mutate(|catalog| catalog.video.renditions.get_mut("video1").unwrap().enabled = false) + .unwrap(); + let media = broadcast.create_track("video1", timed()).unwrap(); + for sequence in 0..4 { + let mut group = media.create_group(sequence.into()).unwrap(); + write_keyframe(&mut group, sequence); + group.finish().unwrap(); + } + + let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); + broadcaster.advertised().await; + until_listed(&broadcaster.rendition(Kind::Video, "video0").unwrap(), 3).await; + for _ in 0..3 { + assert!(broadcaster.manifest(None).unwrap().is_some()); + tokio::time::sleep(Duration::from_secs(1)).await; + } + let disabled = broadcaster.rendition(Kind::Video, "video1").unwrap(); + assert!(!disabled.snapshot().segments.is_empty()); + assert!( + disabled.media_playlist(None).unwrap().is_none(), + "no init was built for the disabled rendition" + ); + + catalog + .mutate(|catalog| catalog.video.renditions.get_mut("video1").unwrap().enabled = true) + .unwrap(); + listed_in_manifest(&broadcaster).await; } // A reconfigure that changes the init bytes changes the init URL, and the old URL stops @@ -2258,7 +1605,7 @@ mod tests { let 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.broadcast().clone()); + let (_catalog, mut media) = publish_vp8(&mut broadcast.broadcast().clone(), video_config()); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -2272,7 +1619,11 @@ 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(); + // The replacement lists only records after the reconfigure. + for micros in [6_000_000, 8_000_000, 10_000_000] { + media.write(vp8_frame(micros, true)).unwrap(); + } let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let new = loop { @@ -2303,13 +1654,14 @@ mod tests { let 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.broadcast().clone()); + let (_catalog, mut media) = publish_vp8(&mut broadcast.broadcast().clone(), 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"); + let tag = rendition.tag().to_string(); 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")), @@ -2322,8 +1674,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), @@ -2331,7 +1683,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!( @@ -2342,7 +1694,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(); @@ -2360,23 +1712,8 @@ mod tests { let 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.broadcast().clone(), 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.broadcast().clone()); + 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(); } @@ -2408,36 +1745,13 @@ 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 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.broadcast().clone(), 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.broadcast().clone(), inline_vp8()); let broadcaster = Broadcaster::new(crate::resolved(&origin, "live").await, Config::default()); let _ = tokio::time::timeout(Duration::from_secs(5), broadcaster.ready()).await; @@ -2450,14 +1764,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 @@ -2468,8 +1777,9 @@ 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")); + let tag = rendition.tag(); + 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()); @@ -2479,7 +1789,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) @@ -2488,60 +1798,76 @@ 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 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.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); + let (catalog, media) = publish_vp8(&mut broadcast.broadcast().clone(), 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"); + let _ = tokio::time::timeout(Duration::from_secs(5), rendition.playable()).await; - let reserved = catalog.reserve(); - let mut registration = catalog.test_video("video0").unwrap(); - registration.set(video_config()).unwrap(); - drop(reserved); + // 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"); - 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(); + 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(1_000_000, false)).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.finish().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 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)); + 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 timeline - // windows as the HLS playlists (dynamic while live, aligned S entries across renditions), + // The DASH half of the fetch-on-demand path: the manifest renders from the same record + // 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() { @@ -2549,32 +1875,9 @@ mod tests { let 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.broadcast().clone(), 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.broadcast().clone()); + 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| { @@ -2591,7 +1894,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; @@ -2609,11 +1911,16 @@ 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\""))); - // Both renditions list the same aligned segment spans (the live edge is not listed). - assert_eq!(manifest.matches("").count(), 2); + 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 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!(!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( @@ -2744,23 +2024,22 @@ 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; 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}"); @@ -2773,7 +2052,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 @@ -2787,16 +2066,14 @@ 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(); let 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.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); + let catalog = hls_catalog(&mut broadcast.broadcast().clone()); let mut import = moq_mux::container::fmp4::Import::new(broadcast.broadcast().clone(), catalog.reserve()); import.decode(&init).unwrap(); @@ -2856,13 +2133,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()); @@ -2911,7 +2188,7 @@ mod tests { let broadcast = origin.create_broadcast("live").unwrap(); broadcast.announce(Default::default()).unwrap(); settle().await; - let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), Default::default()).unwrap(); + let catalog = hls_catalog(&mut broadcast.broadcast().clone()); let mut import = moq_mux::container::fmp4::Import::new(broadcast.broadcast().clone(), catalog.reserve()); import.decode(&init).unwrap(); let snapshot = catalog.snapshot(); @@ -2925,12 +2202,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() { @@ -2965,7 +2242,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); } @@ -2979,29 +2256,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 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.broadcast().clone(), 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.broadcast().clone()); + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(frame(0, true)).unwrap(); media.write(frame(2_000_000, true)).unwrap(); @@ -3009,7 +2272,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(); @@ -3022,11 +2285,10 @@ 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("")); + // The trailing segment holds one frame that nothing bounds, so it lasts no time. HLS + // lists it, 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 one. + 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 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.broadcast().clone(), 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.broadcast().clone()); + let mut audio = audio(&catalog, &broadcast, "audio0", opus()); + // 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(); @@ -3176,15 +2322,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 @@ -3195,25 +2338,9 @@ mod tests { let 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.broadcast().clone(), 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.broadcast().clone()); + // 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(); media.write(frame(6_000_000, true)).unwrap(); @@ -3225,44 +2352,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)); } + // 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"); settle().await; - let catalog = - moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), 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.broadcast().clone()); + 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. @@ -3273,14 +2378,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; @@ -3293,21 +2398,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 cuts the same 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"); @@ -3318,41 +2417,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 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.broadcast().clone(), 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.broadcast().clone()); + // 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; @@ -3364,72 +2446,91 @@ 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, segment.media.is_some())); } - assert!( - groups.contains(&1), - "the final segment survives dropping the broadcaster, got {groups:?}" + // The finish's stop is the trailing gap. + assert_eq!( + listed, + [(0, true), (1, true), (2, false)], + "the final segment survives dropping the broadcaster" ); - drop((catalog, media, registration, broadcast, rendition)); - } - - /// 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. + /// 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, - ) -> (Arc, tokio::task::JoinHandle<()>) { + ) -> (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 broadcast = origin.create_broadcast("live").unwrap(); broadcast.announce(Default::default()).unwrap(); - let mut catalog = - moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), Default::default()).unwrap(); let target = if sibling { let target = origin.create_broadcast("media").unwrap(); target.announce(Default::default()).unwrap(); @@ -3437,19 +2538,20 @@ mod tests { } else { broadcast.clone() }; - let recorder = catalog.enroll_test("video0").unwrap(); - let mut media = write_routed_media(&mut target.broadcast().clone(), KEYFRAME, recorder, 0); + let (_track, mut records) = records(&broadcast); + let mut media = write_routed_media(&mut target.broadcast().clone(), 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").unwrap()); } - 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(); @@ -3461,9 +2563,8 @@ mod tests { ); Ended { rendition, - watcher, origin, - _held: Box::new((catalog, media, broadcast, target)), + _held: Box::new((renditions, media, broadcast, target, records)), } } @@ -3472,10 +2573,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] @@ -3483,7 +2582,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"); } @@ -3492,7 +2590,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"); } @@ -3501,7 +2598,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"); } @@ -3510,7 +2606,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; @@ -3533,8 +2628,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(); @@ -3547,13 +2641,18 @@ mod tests { let mut cursor = ended.rendition.segments(); let mut drained = Vec::new(); while let Some(segment) = cursor.next().await.unwrap() { - let media = segment.media.expect("old media, not a gap"); - assert!(contains(&media, OLD)); - assert!(!contains(&media, NEW)); - drained.push(segment.segment); + if let Some(media) = &segment.media { + assert!(contains(media, KEYFRAME)); + assert!(!contains(media, NEW)); + } + drained.push((segment.segment, segment.media.is_some())); } - assert_eq!(drained, [0, 1, 2], "the recorder drains the complete retained tail"); - ended.watcher.abort(); + // The end's stop is the trailing gap. + assert_eq!( + drained, + [(0, true), (1, true), (2, true), (3, false)], + "the recorder drains the complete retained tail" + ); } } @@ -3569,40 +2668,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 broadcast = origin.create_broadcast("live").expect("publish allowed"); broadcast.announce(Default::default()).expect("publish allowed"); - let catalog = moq_mux::catalog::Producer::new( - &mut broadcast.broadcast().clone(), - 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.broadcast().clone()); + 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. @@ -3615,10 +2693,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 { @@ -3631,16 +2709,15 @@ mod tests { served.is_none(), "the replaced broadcast is closed, so it serves nothing" ); - watcher.abort(); // 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 @@ -3650,59 +2727,43 @@ mod tests { contains(&served, NEW), "the new export serves the new publisher's media" ); - watcher.abort(); 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"); 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), + let media = write_routed_media( + &mut broadcast.broadcast().clone(), + payload, + records.map(|records| (records, 0)), + 0, ); - 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(); - Box::new((broadcast, media)) } let origin = produce_origin(); - // The catalog broadcast carries the catalog and the timeline; the media lives next door. + // 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.broadcast().clone(), 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; @@ -3712,10 +2773,10 @@ mod tests { let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("media").unwrap()); - 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. @@ -3736,16 +2797,13 @@ 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 @@ -3755,9 +2813,8 @@ mod tests { contains(&served, NEW), "the new export serves the new publisher's media" ); - watcher.abort(); - drop((new_media, catalog, live)); + drop((new_media, records, live)); } fn hops(ids: &[u64]) -> moq_net::Hops { @@ -3772,92 +2829,93 @@ 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 { if rendition.snapshot().finished { return; } - assert!( - tokio::time::Instant::now() < deadline, - "the old sibling's playlist did not end" - ); + assert!(tokio::time::Instant::now() < deadline, "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::origin::Publication, + 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").unwrap()); + 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::origin::Publication, + moq_mux::container::Producer, + ) { + let media = self.origin.create_broadcast("media").unwrap(); + let track = write_routed_media( + &mut media.broadcast().clone(), + 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 live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = - moq_mux::catalog::Producer::new(&mut live.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let old_media = origin.create_broadcast("media").unwrap(); - let _old_track = write_routed_media(&mut old_media.broadcast().clone(), 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").unwrap()); - - 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) @@ -3865,24 +2923,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.expect("old media, not a gap"), OLD)); } + let before = rendition.snapshot().segments.len(); - // A publisher serving the path afterwards, with rows recorded for it, is never bound. - let 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.broadcast().clone(), 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 @@ -3890,38 +2943,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 live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = - moq_mux::catalog::Producer::new(&mut live.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let media = origin.create_broadcast("media").unwrap(); - let _track = write_routed_media(&mut media.broadcast().clone(), 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").unwrap()); - - 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)); @@ -3930,8 +2963,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 @@ -3942,7 +2975,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 @@ -3951,35 +2983,21 @@ mod tests { async fn a_sibling_present_at_export_start_is_served() { const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - let origin = produce_origin(); - 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.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let media = origin.create_broadcast("media").unwrap(); - let _track = write_routed_media(&mut media.broadcast().clone(), 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").unwrap()); - - 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 @@ -3991,45 +3009,23 @@ mod tests { const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; const NEW: &[u8] = b"NEWNEWNEWNEWNEWN"; - let origin = produce_origin(); - 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.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - - let old_media = origin.create_broadcast("media").unwrap(); - let _old_track = write_routed_media(&mut old_media.broadcast().clone(), 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").unwrap()); - - 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 new_media = origin.create_broadcast("media").unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - let _new_track = write_routed_media(&mut new_media.broadcast().clone(), 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"); @@ -4041,36 +3037,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 live = origin.create_broadcast("live").expect("publish allowed"); - live.announce(Default::default()).expect("publish allowed"); - let catalog = - moq_mux::catalog::Producer::new(&mut live.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - let media = origin.create_broadcast("media").unwrap(); - let track = write_routed_media(&mut media.broadcast().clone(), 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").unwrap()); - 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 { @@ -4084,15 +3068,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 @@ -4102,27 +3085,14 @@ mod tests { tokio::time::pause(); const OLD: &[u8] = b"OLDOLDOLDOLDOLDO"; - let origin = produce_origin(); - 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.broadcast().clone(), moq_mux::catalog::Config::default()) - .unwrap(); - let recorder = catalog.enroll_test("video0").unwrap(); - let media = origin.create_broadcast("media").unwrap(); - let _track = write_routed_media(&mut media.broadcast().clone(), 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").unwrap()); - - 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) @@ -4130,13 +3100,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(); @@ -4146,20 +3114,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") @@ -4169,75 +3128,16 @@ mod tests { drop((broadcast, rendition)); } - // A rendition is hidden until its own timeline is advertised, then added beside its siblings. - #[tokio::test] - async fn a_rendition_appears_once_its_timeline_is_advertised() { - 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; - - 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. + // `Arc` it was handed) outlives it. #[tokio::test] async fn dropping_the_broadcaster_releases_its_renditions() { let origin = produce_origin(); let 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.broadcast().clone(), 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.broadcast().clone()); + let mut media = video(&catalog, &broadcast, "video0", video_config()); media.write(frame(0, true)).unwrap(); media.write(frame(2_000_000, true)).unwrap(); @@ -4262,43 +3162,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 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.broadcast().clone(), 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.broadcast().clone()); + // 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(); @@ -4317,292 +3201,135 @@ mod tests { assert_eq!(rendition.kind, Kind::Video); assert_eq!(rendition.name, "video0"); - let mut segments = rendition.segments(); - assert!(segments.init().await.unwrap().is_some(), "init is buildable"); - - let first = tokio::time::timeout(Duration::from_secs(5), segments.next()) - .await - .expect("first segment finalizes") - .unwrap() - .expect("a segment, not end"); - assert_eq!(first.segment, 0); - let fragment = first.media.expect("media, not a gap"); - assert_eq!(&fragment[4..8], b"moof", "the segment carries its transmuxed media"); - assert_eq!(first.duration, Duration::from_secs(2)); - assert_eq!(first.discontinuity, 0, "a clean start is not a discontinuity"); - - let second = segments.next().await.unwrap().expect("second segment"); - 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)); - 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 { .. })), - "an abrupt end fails the recording: {:?}", - end.map(|segment| segment.map(|segment| segment.segment)) - ); - - // The renditions cursor also fails when the source closes abruptly. - let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next()) - .await - .expect("renditions cursor resolves"); - 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}" - ); - } - } + let mut segments = rendition.segments(); + assert!(segments.init().await.unwrap().is_some(), "init is buildable"); - #[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))); - } + let first = tokio::time::timeout(Duration::from_secs(5), segments.next()) + .await + .expect("first segment finalizes") + .unwrap() + .expect("a segment, not end"); + assert_eq!(first.segment, 0); + let fragment = first.media.expect("media, not a gap"); + assert_eq!(&fragment[4..8], b"moof", "the segment carries its transmuxed media"); + assert_eq!(first.duration, Duration::from_secs(2)); + assert_eq!(first.discontinuity, 0, "a clean start is not a discontinuity"); - #[tokio::test(start_paused = true)] - async fn a_recording_refuses_missing_media_but_reports_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 second = segments.next().await.unwrap().expect("second segment"); + assert_eq!(second.segment, 1); + assert_eq!(second.discontinuity, 0, "consecutive segments are continuous"); - // The gap keeps its number, so a recorder can tell it from rows it missed. - let rendition = test.rendition("video1"); - let mut cursor = rendition.segments(); - let gap = cursor.next().await.unwrap().unwrap(); - assert_eq!((gap.segment, gap.media.is_none()), (0, true)); - let covered = cursor.next().await.unwrap().unwrap(); - assert_eq!((covered.segment, covered.media.is_some()), (1, true)); - // Its timeline finished before segment 2, which is a gap too. - let tail = cursor.next().await.unwrap().unwrap(); - assert_eq!((tail.segment, tail.media.is_none()), (2, true)); - assert!(cursor.next().await.unwrap().is_none()); - } + // 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(); - #[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") - ); - } - } + 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::RecordsFailed { .. })), + "an abrupt end fails the recording: {:?}", + end.map(|segment| segment.map(|segment| segment.segment)) + ); + + // The renditions cursor also fails when the source closes abruptly. + let ended = tokio::time::timeout(Duration::from_secs(5), renditions.next()) + .await + .expect("renditions cursor resolves"); + assert!(ended.is_err(), "an abrupt broadcast close fails the renditions cursor"); } - #[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 record covering group `sequence` from `2 * sequence` seconds. + fn gop(sequence: u64) -> hang::hls::Record { + hang::hls::Record::new(sequence, sequence, sequence * 2_000) } - /// 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), - ) + /// 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) } - /// 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 { + /// `video0` and `video1` renditions. 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()) + } + + /// Reconcile the current catalog again. + fn sync(&self) { + self.renditions.sync(&Self::source(&self.broadcast), &self.catalog); } - /// Another exporter over the same timelines: a second edge, or one a fresh viewer starts. + /// 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); @@ -4675,24 +3402,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(); @@ -4702,7 +3423,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; @@ -4710,619 +3431,449 @@ mod tests { panic!("{} never failed", rendition.name); } - fn numbers(snapshot: &playlist::Snapshot) -> Vec { - snapshot.segments.iter().map(|s| s.segment).collect() - } - - // 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"); - 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. - assert_eq!(numbers(&test.listed("video1", 2).await), [0, 1, 2]); - - cut(track, video1); - for sequence in 4..7 { - video0.push(&gop(sequence)).unwrap(); - } - test.failed("video1").await; - let video1 = test.rendition("video1"); - assert!(video1.media_playlist(None).is_err()); - assert!(video1.representations().is_err()); - // 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]); - - let reference = test.listed("video0", 6).await; - assert!(!reference.finished, "the other renditions go on"); - assert!(test.rendition("video0").representations().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. #[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; - } - 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]); - } - } + 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))); } - // 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)]; - 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}"); + 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}" + ); } - 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]); - } - - // 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. - #[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"); - let (track, video1) = test.publish("video1"); - let video1_rendition = test.rendition("video1"); - let pending = tokio::spawn(async move { video1_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!(test.renditions.failure().is_some()); } - // The same for the reference timeline, which every playlist lists. #[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"); - - 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}"); + 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 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 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_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 { + async fn a_record_error_fails_only_its_playlist() { + let test = Records::new(); + 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 { 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) - ); - } - - // 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. - #[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 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.broadcast().clone(), 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; - } - assert!(reloads >= 5, "the playlist did not stay reloadable"); + // The newest record waits for its successor. + assert_eq!(numbers(&test.listed("video1", 2).await), [0, 1, 2]); - 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" - ); + cut(track, video1); + for sequence in 4..7 { + video0.push(&gop(sequence)).unwrap(); + } + test.failed("video1").await; + let video1 = test.rendition("video1"); + assert!(video1.media_playlist(None).is_err()); + assert!(video1.representation().is_err()); + assert!(video1.failed_alone()); + // Segments already listed stay fetchable while the listing refuses. + 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]); - drop((media, earlier_media, video0, added, broadcast)); + let video0 = test.listed("video0", 5).await; + assert!(!video0.finished, "the other renditions go on"); + assert!(test.rendition("video0").representation().is_ok()); } - #[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()); + // A failed rendition leaves the broadcast-wide listings, so new players start on a sibling. + #[tokio::test(start_paused = true)] + 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.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); } - println!( - "hls handoff renditions={count} retained={retained} ns_per_push={}", - elapsed.as_nanos() / 1_024 - ); + }) + .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(); + } + 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; } } + 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 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")) - ); + // 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_record_failure() { + let test = Records::new(); + let (track, video1) = test.publish("video1"); + 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::RecordsFailed { .. }), "{err}"); } - #[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()); + // Malformed records fail their rendition the same way. + #[tokio::test(start_paused = true)] + 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; + + let mut group = track.append_group().unwrap(); + group.write_frame(moq_net::Timestamp::now(), &b"garbage"[..]).unwrap(); + test.failed("video0").await; } - // 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())); + // 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_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.requests(); + tokio::time::sleep(Duration::from_secs(60)).await; + assert!(!test.rendition("video0").snapshot().finished); - 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); + test.broadcast.close(); + test.failed("video0").await; + assert!(test.drain_failed("video0").await.is_empty()); } - // 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 recording refuses a listed segment whose media is gone, but reports a segment the + // 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_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 { + async fn a_recording_refuses_missing_media_but_reports_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(); } - // 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(); - } - 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(), + video0.push(&stop(3, 6_000)).unwrap(); + video1.push(&stop(0, 0)).unwrap(); + video1.push(&gop(1)).unwrap(); + video1.push(&stop(2, 4_000)).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 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"); + + let snapshot = test.finished("video1").await; + assert_eq!(gaps(&snapshot), [0, 2]); + let rendition = test.rendition("video1"); assert!( - video0.numbered(&old, 0).await.unwrap().is_none(), - "the old URL is no longer served" + rendition.listed_segment(0).await.unwrap().is_none(), + "a gap is never fetched" ); + let mut cursor = rendition.segments(); + let mut next = async || { + let segment = cursor.next().await.unwrap().unwrap(); + (segment.segment, segment.media.is_some()) + }; + assert_eq!(next().await, (0, false)); + assert_eq!(next().await, (1, true)); + assert_eq!(next().await, (2, false)); + assert!(cursor.next().await.unwrap().is_none()); } - // 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. + // 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_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(); + 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(); } + // 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(); + } + 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!(rendition.is_advertised()); + let dash = rendition.representation().unwrap().expect("an out-of-band init"); + assert_eq!(dash.segments, [(5_500, 2_500), (8_000, 2_000)]); + } + + // A break in one rendition starts a discontinuity on it alone: each rendition counts its own. + #[tokio::test(start_paused = true)] + 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"); 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(); + video1.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)); + video0.push(&gop(0)).unwrap(); + 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(); + + 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 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. #[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 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}"); + } - 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; + // 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}"); } - // 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. + // 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_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.requests(); - tokio::time::sleep(Duration::from_secs(60)).await; - assert!(!test.rendition("video0").snapshot().finished); + 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(); - test.broadcast.close(); - test.failed("video0").await; - assert!(test.drain_failed("video0").await.is_empty()); + let snapshot = test.listed("video0", 2).await; + assert_eq!(numbers(&snapshot), [0, 1, 2]); + assert_eq!(gaps(&snapshot), [1]); } - /// 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), - ) + #[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. What bounds it + // is no longer the old instance's. + 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 { + let Some(renditions::Event::Added(new)) = changes.next().await.unwrap() else { + panic!("the replacement is reported"); + }; + // The replacement muxes the same records differently, so it lists them under its + // own URLs, and only those its predecessor didn't. + assert_ne!(new.tag(), rendition.tag(), "one URL never names two muxes"); + records.push(&gop(3)).unwrap(); + assert_eq!(numbers(&test.listed("video0", 2).await), [2]); + } + } } - /// 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() + // 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 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; @@ -5332,13 +3883,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); } @@ -5346,38 +3896,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; } @@ -5386,212 +3937,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..6b5cb9c4a8d 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,11 +219,11 @@ 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. + // 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); @@ -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, @@ -440,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 336b47df054..fd656d4c5dd 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. @@ -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>, } @@ -41,15 +38,19 @@ 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, /// 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, + /// 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 7fdff9b33a5..2584ab89816 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -1,20 +1,18 @@ -//! 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::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 +27,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 +65,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 +97,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 +157,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 +175,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(); } @@ -247,8 +243,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 } @@ -264,7 +259,36 @@ fn normalize_audio(config: &AudioConfig) -> AudioConfig { config } -/// A single HLS rendition: master-playlist metadata, its view of the broadcast timeline (which +/// The segment [tag](segments::tag) of `track` muxed under the normalized `config`, hashing its +/// catalog JSON, which every edge encodes alike. +fn tag(track: &str, config: &T) -> Arc { + // It was parsed from catalog JSON, so it encodes back. + let config = serde_json::to_vec(config).expect("a catalog config encodes as JSON"); + Arc::from(segments::tag(track, &config)) +} + +/// 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::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. + next: u64, + /// Set by [`Rendition::retire`]: nothing at or past `next` is listed. + retired: bool, +} + +/// 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. @@ -275,7 +299,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 @@ -289,20 +313,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 muxed under this config, 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: discontinuity counts and the run. + fanout: renditions::Fanout, + /// 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, + /// Which records this instance lists. + claim: Mutex, /// The broadcast serving this rendition's media. A sibling is bound when the rendition is /// created, and ends the rendition when it ends. Shared with a background init build. media: Arc, @@ -324,9 +354,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; @@ -342,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 { @@ -376,8 +412,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. @@ -385,8 +421,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 { @@ -399,12 +436,15 @@ impl Rendition { height: config.coded_height, codec: config.codec.to_string(), config: Config::Video(config.clone()), - section, + tag: tag(&hls.track, &normalize_video(config)), + hls, clock, - live: Arc::new(segments::Producer::new()), - spans: Arc::new(Spans::new()), + live: Arc::new(segments::Producer::new(0)), + fanout, catalog: source.broadcast(), watcher: Mutex::new(None), + retiring: tokio::sync::watch::Sender::new(false), + claim: Mutex::default(), media: Arc::new(media), run: kio::Producer::default(), building: Arc::new(tokio::sync::Mutex::new(())), @@ -413,14 +453,15 @@ impl Rendition { }) } - /// 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 { @@ -433,12 +474,15 @@ impl Rendition { height: None, codec: config.codec.to_string(), config: Config::Audio(config.clone()), - section, + tag: tag(&hls.track, &normalize_audio(config)), + hls, clock, - live: Arc::new(segments::Producer::new()), - spans: Arc::new(Spans::new()), + live: Arc::new(segments::Producer::new(0)), + fanout, catalog: source.broadcast(), watcher: Mutex::new(None), + retiring: tokio::sync::watch::Sender::new(false), + claim: Mutex::default(), media: Arc::new(media), run: kio::Producer::default(), building: Arc::new(tokio::sync::Mutex::new(())), @@ -447,79 +491,68 @@ impl Rendition { }) } - /// 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); - } - self.trim(); + /// 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, + retired: false, + }); + self.live = Arc::new(segments::Producer::new(start)); + self } - /// 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); + /// End the window once the segment its records are still bounding is listed, because the + /// 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) -> Handoff { + let next = { + let mut claim = self.claim.lock().expect("claim lock poisoned"); + claim.retired = true; + claim.next + }; + self.retiring.send_replace(true); + Handoff { + track: self.hls.track.clone(), + next, } } - /// 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, - }; - } - self.spans - .resolve(self.kind == Kind::Video, Duration::from(row.pts)..row.end) + /// This rendition's identity in its broadcast. + fn key(&self) -> renditions::Key { + (self.kind, self.name.clone()) } - /// Whether the rendition declares no media for this row. - pub(crate) fn is_gap(&self, row: &segments::Row) -> bool { - self.resolve(row) == Content::Gap + /// The [tag](segments::tag) every segment URL carries. + #[cfg(test)] + pub(crate) fn tag(&self) -> &str { + &self.tag } - /// Poll until `row` resolves on this rendition. - pub(crate) fn poll_resolved(&self, waiter: &kio::Waiter, row: &segments::Row) -> Poll<()> { - if self.is(&row.reference) { - return Poll::Ready(()); + /// 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.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(); } /// List every segment URL under `generation` from now on, keeping the run's rows and init. @@ -527,8 +560,7 @@ impl Rendition { self.run_mut().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. @@ -539,14 +571,11 @@ impl Rendition { build.abort(); } self.live.clear(); - // The old run's own records would bootstrap an inline init from its media. - self.spans.clear(); let mut run = self.run_mut(); *run = Run { generation, init: None, epoch: run.epoch + 1, - startable: None, }; } @@ -576,17 +605,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()) @@ -605,16 +634,14 @@ 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 (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(()); } - if self.spans.poll_failed(waiter).is_ready() { - return Poll::Ready(()); - } - self.live.poll_playable(waiter) + self.live.poll_listed(waiter) } /// End the window if the bound sibling ended, registering `waiter` to wake when it does, and @@ -623,14 +650,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. @@ -651,9 +679,8 @@ 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))) + 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 @@ -663,12 +690,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. @@ -678,48 +705,35 @@ 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. - 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)) } - /// 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() { @@ -728,37 +742,29 @@ 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() } /// Poll until listings of this rendition refuse (see [`check`](Self::check)), waking when its - /// media is denied or either timeline fails. + /// media is denied or its records fail. #[cfg_attr( not(feature = "server"), allow(dead_code, reason = "only the `server` feature uses it") )] pub(crate) fn poll_refused(&self, waiter: &kio::Waiter) -> Poll<()> { self.poll_media(waiter); - let _ = self.spans.poll_failed(waiter); let _ = self.live.poll_failed(waiter); match self.check() { Err(_) => Poll::Ready(()), @@ -767,27 +773,30 @@ impl Rendition { } 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); - 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: segments::tag(&s.reference), + tag: s.tag.to_string(), duration: s.duration, - gap: content == Content::Gap, + gap: s.stop, discontinuity, + program_date_time: self.wall_clock(s.pts), } }) .collect(); @@ -797,43 +806,14 @@ impl Rendition { discontinuity_sequence: window.discontinuity, segments, finished, - program_date_time, generation, } } - /// `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. - 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 - } - - /// 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 - /// 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. /// - /// 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() } @@ -844,52 +824,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(()) - } - - /// 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_mut(); - run.startable = run.startable.max(Some(resets)); + self.poll_playable(waiter) } - /// 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); @@ -898,64 +840,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 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) + }) + // 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), }, @@ -963,28 +892,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(); @@ -1005,22 +930,15 @@ impl Rendition { Poll::Ready(track.map(|(_, track)| track.subscribe(subscription).into_inner())) } - /// 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())) @@ -1053,7 +971,7 @@ impl Rendition { } /// Build the init in the background unless it is built or a build is already running, so a - /// later [`representations`](Self::representations) lists this rendition without any render + /// later [`representation`](Self::representation) lists this rendition without any render /// waiting on its media. /// /// The build never holds the rendition itself, and is aborted when the rendition drops, so @@ -1135,22 +1053,17 @@ impl Rendition { Ok(Some(self.cache_init(run.epoch, bytes))) } - /// The indexed keyframe groups an inline-codec init is read from, or `None` while there are - /// none. + /// The listed groups an inline-codec init is read from, or `None` while none is listed. fn init_read(&self, muxer: Muxer) -> Option { - let mut groups = self.spans.keyframe_groups(); - if groups.is_empty() { - let reference = (self.kind, self.name.clone(), self.track_name().to_string()); - groups = self.live.keyframe_groups(&reference); - } + let groups = self.live.init_groups(); (!groups.is_empty()).then_some(InitRead { muxer, groups }) } /// 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 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. @@ -1164,12 +1077,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()?; @@ -1179,26 +1092,23 @@ 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 { + 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 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 { + if groups.end.saturating_sub(groups.start) > MAX_SEGMENT_GROUPS { tracing::warn!( - total, + ?groups, "segment spans more than MAX_SEGMENT_GROUPS; refusing to truncate" ); return Ok(None); @@ -1214,43 +1124,34 @@ 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); } - let fragment = muxer.fragment(row.segment as u32, &frames)?; + // The last presented sample has no successor to time it, but the record says where the + // 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 timeline tail. + // Reuse it instead of fetching a newer, possibly unfinished group. if let Some(bytes) = muxer.init()? { self.cache_init(run.epoch, bytes); } @@ -1258,85 +1159,180 @@ 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 - } - - /// 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)?)) + self.numbered(&self.tag.clone(), segment).await } - /// 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(()); + }; + // Held while handling the event, so a retire sees exactly what this instance lists. + let mut claim = rendition.claim.lock().expect("claim lock poisoned"); 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 { entry, .. } if entry.sequence < claim.start => {} + moq_mux::hls::Event::Push { entry, .. } => { + if let Some(previous) = pending.take() { + match row(&rendition, &previous, &entry, broken) { + Some(row) => { + broken = false; + rendition.push(row); + } + // 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; + } + } + } + // It bounded the record a retired rendition was waiting on, and starts what + // the replacement lists. + if claim.retired { + return Ok(()); + } + if entry.is_stop() { + rendition.push(gap(&rendition, &entry, broken)); + // Whatever resumes does not continue it. + broken = true; + } + claim.next = entry.sequence + 1; + if !entry.is_stop() { + pending = Some(entry); + } + } + 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(range) => { + // The pending record's successor is gone, so a retired rendition lists no more. + if claim.retired { + return Ok(()); + } + pending = None; + broken = true; + claim.next = claim.next.max(range.end); + 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(); +} + +/// A row listing `record`'s number with nothing to fetch, as `EXT-X-GAP`: a stop, or a record +/// no successor can bound. +fn gap(rendition: &Rendition, record: &moq_mux::hls::Entry, broken: bool) -> segments::Row { + let pts = Duration::from(record.pts); + segments::Row { + index: record.sequence, + segment: record.sequence, + tag: rendition.tag.clone(), + groups: record.group..record.group, + duration: Duration::ZERO, + pts: record.pts, + end: pts, + discontinuity: rendition.fanout.stamp(&rendition.key(), broken, pts), + stop: true, + complete: false, + } +} + +/// 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, + 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's successor holds none of its groups; listing it as a gap" + ); + 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(&rendition.key(), broken, end), + stop: false, + complete: !next.is_stop(), + }) } /// An inline-codec init still to be read from media: owned, so a background build waits on it /// without holding its rendition. struct InitRead { muxer: Muxer, - /// Keyframe groups to try in order: a closed group first, since reading an open one waits - /// for the publisher to finish it. + /// Groups to try in order (see [`segments::Producer::init_groups`]): a complete group first, + /// since reading an open one waits for the publisher to finish it. groups: Vec, } @@ -1348,8 +1344,7 @@ impl InitRead { }; for sequence in self.groups { // A group gone from the cache, before or mid-read, leaves the next candidate to try. - 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 { continue; }; if read_group(&mut self.muxer, &mut group, &broadcast).await?.is_err() { @@ -1376,20 +1371,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()), } @@ -1487,6 +1475,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(); @@ -1495,51 +1487,36 @@ 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").unwrap()); - 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.requests("room/hidden").unwrap(); - config.broadcast = Some(moq_net::path::Relative::new("./hidden").unwrap()); - 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, + complete: true, }; - 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))) @@ -1674,34 +1651,28 @@ mod tests { assert!(!is_cache_miss(&moq_net::Error::Remote(0))); } - // A wait on a rendition's refusal wakes when either timeline fails mid-wait, so the - // manifest's first-video wait drops a video that can no longer be listed. + // A wait on a rendition's refusal wakes when its records fail mid-wait, so the manifest's + // first-video wait drops a video that can no longer be listed. #[tokio::test(start_paused = true)] - async fn a_refusal_wait_wakes_on_a_timeline_failure() { - for own in [true, false] { - let origin = moq_tokio::origin::spawn(); - let broadcast = origin.create_broadcast("live").unwrap(); - let source = moq_mux::Source::new(origin.consume(), broadcast.consume()); - let config = VideoConfig::new(hang::catalog::VideoCodec::VP8); - let rendition = - Arc::new(Rendition::video("video".into(), &config, &source, Archive::default(), None).unwrap()); - // On its own task, so only a registered wake polls it again. - let refused = tokio::spawn({ - let rendition = rendition.clone(); - async move { kio::wait(|waiter| rendition.poll_refused(waiter)).await } - }); - moq_clock::sleep(Duration::from_millis(10)).await; - assert!(!refused.is_finished(), "no timeline failed yet"); - let err = crate::Error::Moq(moq_net::Error::Transport("connection lost".into())); - if own { - rendition.spans.fail(err, false); - } else { - rendition.fail(err); - } - moq_clock::timeout(Duration::from_secs(1), refused) - .await - .expect("the failure wakes the wait") - .unwrap(); - } + async fn a_refusal_wait_wakes_on_a_records_failure() { + let origin = moq_tokio::origin::spawn(); + let broadcast = origin.create_broadcast("live").unwrap(); + let source = moq_mux::Source::new(origin.consume(), broadcast.consume()); + let config = VideoConfig::new(hang::catalog::VideoCodec::VP8); + let renditions = renditions::Producer::new(Duration::from_secs(30)); + let rendition = + Arc::new(Rendition::video("video".into(), &config, &source, hls(), None, renditions.fanout()).unwrap()); + // On its own task, so only a registered wake polls it again. + let refused = tokio::spawn({ + let rendition = rendition.clone(); + async move { kio::wait(|waiter| rendition.poll_refused(waiter)).await } + }); + moq_clock::sleep(Duration::from_millis(10)).await; + assert!(!refused.is_finished(), "the records have not failed yet"); + rendition.fail(crate::Error::Moq(moq_net::Error::Transport("connection lost".into()))); + moq_clock::timeout(Duration::from_secs(1), refused) + .await + .expect("the failure wakes the wait") + .unwrap(); } } diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index f572a02dc89..6faefc92e49 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -1,36 +1,69 @@ //! 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 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 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, BTreeSet}; 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::master; use super::rendition::{Kind, Rendition}; -use super::segments::{self, Discontinuities, Reference}; /// 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] 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). @@ -42,38 +75,22 @@ 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. + /// Every rendition created and still alive, the dropped ones pruned as the next registers. 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. + /// Each rendition's discontinuity count. 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>, @@ -82,16 +99,6 @@ struct Feed { 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 producing side of a broadcast's rendition set. /// /// [`sync`](Self::sync) reconciles it against a catalog snapshot; [`close`](Self::close) marks @@ -109,13 +116,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)] @@ -130,14 +137,8 @@ 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, @@ -146,23 +147,29 @@ impl Producer { } } - /// The timeline fan-out handle for the timeline watcher task. + /// 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() } /// 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() } @@ -208,13 +215,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 @@ -222,8 +222,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(); @@ -231,13 +231,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); @@ -247,134 +247,41 @@ 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 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.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(key, broken) } - /// Remove records that left the source timeline window from every rendition window. - pub fn pop(&self, range: std::ops::Range, reference: &Reference) { - 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 - }); + /// Why the catalog failed, if it did. + pub fn failure(&self) -> Option { + self.feed.read().failed.clone() } /// 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 { @@ -383,9 +290,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| { @@ -401,62 +306,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; @@ -465,7 +321,6 @@ impl Fanout { rendition.close(); true }); - true } } @@ -534,8 +389,8 @@ impl Producer { .peekable(); if video.peek().is_none() || video.any(|rendition| { - // Rerun on every wake: `poll_refused` registered `waiter` on both timelines, - // so the rows a build needs wake this wait to start it. + // Rerun on every wake: `poll_refused` registered `waiter` on its records, so + // the rows a build needs wake this wait to start it. rendition.start_init(); rendition.poll_init(waiter).is_ready() && rendition.poll_advertised(waiter).is_ready() }) { @@ -554,9 +409,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 @@ -564,20 +418,24 @@ 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(); } + // 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 @@ -593,11 +451,10 @@ 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 @@ -606,22 +463,13 @@ impl Producer { /// `..`, which no URL path segment can address, so the master playlist, the DASH manifest, and /// the router agree on what is 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 @@ -641,10 +489,12 @@ impl Producer { }) .map(|(key, _)| key.clone()) .collect(); + // A replacement lists the records its retired predecessor won't, so no record is muxed + // under both configs. + let mut handoffs = BTreeMap::new(); for key in stale { if let Some(rendition) = current.renditions.remove(&key) { - rendition.end(); - rendition.close(); + handoffs.insert(key, rendition.retire()); } } @@ -664,11 +514,11 @@ 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) { - Ok(rendition) => Arc::new(rendition), + }; + let rendition = match Rendition::video(name.clone(), video, source, hls, clock, self.fanout()) { + Ok(rendition) => Arc::new(rendition.after(handoffs.get(&key))), Err(err) => { tracing::warn!(rendition = name, %err, "ignoring unservable video rendition"); continue; @@ -690,11 +540,11 @@ 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) { - Ok(rendition) => Arc::new(rendition), + }; + let rendition = match Rendition::audio(name.clone(), audio, source, hls, clock, self.fanout()) { + Ok(rendition) => Arc::new(rendition.after(handoffs.get(&key))), Err(err) => { tracing::warn!(rendition = name, %err, "ignoring unservable audio rendition"); continue; @@ -703,6 +553,12 @@ 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"); + } + // 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 @@ -728,7 +584,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); @@ -746,32 +602,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, } } @@ -806,23 +652,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(); @@ -837,162 +674,42 @@ mod tests { } #[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)) - )); + 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_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 35037bcf8c7..8f0aaff9182 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. 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; @@ -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. + /// 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, } -/// 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,69 @@ 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. -/// -/// 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. -#[derive(Default)] -pub(crate) struct Discontinuities { - 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; - } - self.broken = false; - self.last_end = Some(end); - self.sequence - } - - /// Records were skipped (or a new run started): the next one starts a new discontinuity. - pub fn interrupt(&mut self) { - self.broken = true; - } -} - -/// 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 the segments one config muxes from a record track: a hash of the track name +/// and the normalized `config`, so every edge derives the same one and it never contains a URI +/// separator. A reconfigured rendition keeps its record track but muxes it differently, so its +/// segments get new URLs rather than new bytes under the old ones. +pub(crate) fn tag(track: &str, config: &[u8]) -> String { + let hash = Sha256::new() + .chain_update(track) + .chain_update([0]) + .chain_update(config) + .finalize(); + // 32 bits: the tag only has to tell apart the configs one rendition name ever carries. + hash[..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 rendition's discontinuity 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) - } + /// 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. + pub complete: bool, } /// A consistent read of the window, for rendering one playlist (the serve path only). @@ -230,16 +153,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 +169,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, @@ -284,12 +206,13 @@ impl State { } impl Producer { - /// An empty window. - pub fn new() -> Self { + /// An empty window whose first segment is numbered `start` or later: those before it belong + /// to another window. + pub fn new(start: u64) -> Self { Self { state: kio::Producer::new(State { rows: VecDeque::new(), - sequence: 0, + sequence: start, discontinuity: 0, ended: false, failed: None, @@ -299,25 +222,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 +247,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 +266,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 +274,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 +301,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,77 +310,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 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 + /// 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_scale(timescale) == time as u128 && self::tag(&row.reference) == tag) + .find(|row| !row.stop && row.pts.as_millis() == time as u128 && &*row.tag == tag) .map(|row| row.segment) } - /// The groups of `reference` a row starts on a keyframe, to bootstrap an inline-parameter-set - /// codec's init from, in the order to try them (see [`super::spans::bootstrap_groups`]). - pub fn keyframe_groups(&self, reference: &ReferenceKey) -> Vec { + /// 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 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(); - super::spans::bootstrap_groups( - state - .rows - .iter() - .rev() - .filter(|row| *row.reference == *reference && row.starts_sync()) - .map(|row| row.frames.clone()), - ) - } - - /// Whether the playlist has anything to serve yet (at least one segment, or the broadcast - /// 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() + 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(); + }; + if complete(newest) { + return vec![newest.groups.start]; + } + let older = media.find(|row| complete(row)).map(|row| row.groups.start); + older.into_iter().chain([newest.groups.start]).collect() } - /// 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") - )] - 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() { - Poll::Ready(()) - } else { - Poll::Pending + /// 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, } - }); - 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 until the reference timeline fails. + /// Poll until the window fails. #[cfg_attr( not(feature = "server"), allow(dead_code, reason = "only the `server` feature uses it") @@ -495,19 +380,6 @@ 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, - } - } - /// A cursor over segments, starting from the oldest still in the window. pub fn subscribe(&self, rendition: Arc) -> Consumer { let consumer = self.state.consume(); @@ -523,7 +395,7 @@ impl Producer { /// 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`], /// or `None` for a gap: the rendition has no media for this span (`EXT-X-GAP`), but the @@ -531,9 +403,9 @@ pub struct Segment { pub media: Option, /// 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 @@ -543,7 +415,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 @@ -585,38 +457,22 @@ 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 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. + /// 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> { 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. - 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); - } - let media = if self.rendition.is_gap(&row) { + let media = if row.stop { None } else { let media = self.rendition.fetch(&row).await?; @@ -699,20 +555,37 @@ 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", b"")), + groups: group..group + 1, duration: Duration::from_millis(duration_ms), pts, end: Duration::from(pts) + Duration::from_millis(duration_ms), discontinuity: 0, + stop: false, + complete: true, } } + // 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(); + let live = Producer::new(0); live.push(row(0, 0, 0, 2_000), Duration::from_secs(30)); live.push(row(1, 1, 2_000, 2_000), Duration::from_secs(30)); @@ -729,7 +602,7 @@ mod tests { #[test] fn window_evicts_and_advances_sequence() { - let live = Producer::new(); + let live = Producer::new(0); let window = Duration::from_secs(4); for i in 0..6u64 { live.push(row(i, i, i * 2_000, 2_000), window); @@ -737,7 +610,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)); @@ -746,7 +619,7 @@ mod tests { #[test] fn retiring_a_prefix_advances_the_playlist_sequence() { - let live = Producer::new(); + let live = Producer::new(0); let window = Duration::from_secs(30); for i in 0..4u64 { live.push(row(i, i, i * 2_000, 2_000), window); @@ -768,7 +641,7 @@ mod tests { #[test] fn a_skipped_source_range_clears_rows_before_the_next_segment() { - let live = Producer::new(); + let live = Producer::new(0); live.push(row(4, 4, 8_000, 2_000), Duration::from_secs(10)); live.clear(); live.push(row(10, 10, 20_000, 2_000), Duration::from_secs(10)); @@ -781,88 +654,71 @@ 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); - } - - #[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); - } - - #[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() { - let live = Producer::new(); + fn rows_and_the_init_groups() { + let live = Producer::new(0); let window = Duration::from_secs(30); + assert!(live.init_groups().is_empty()); live.push(row(0, 0, 0, 1_000), window); + assert_eq!(live.init_groups(), [0], "a listed row's successor already started"); 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); + let tag = tag("video.hls", b""); + 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 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); + // Only the stop after it bounds group 2, which the row says before the stop is listed. + live.push( + Row { + complete: false, + ..row(3, 2, 2_000, 1_000) + }, + window, + ); assert_eq!( - live.row(&tag, 0).unwrap().frames, - hang::timeline::Position::group(0)..hang::timeline::Position::group(1) + live.init_groups(), + [1, 2], + "a group only a stop bounds may be open, so a complete one goes first" ); - 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.keyframe_groups(&video), [0], "row 1 does not start on a keyframe"); - assert!( - live.keyframe_groups(&(Kind::Audio, "video".to_string(), "video".to_string())) - .is_empty() + live.push( + Row { + stop: true, + ..row(4, 3, 3_000, 0) + }, + window, ); + assert_eq!(live.init_groups(), [1, 2]); } #[test] fn backwards_jump_resets_the_window() { - let live = Producer::new(); + let live = Producer::new(0); 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 cursor_walks_segments() { - let live = Producer::new(); + let live = Producer::new(0); let window = Duration::from_secs(30); live.push(row(0, 0, 0, 2_000), window); live.push(row(1, 1, 2_000, 2_000), window); @@ -887,7 +743,7 @@ mod tests { #[test] fn cursors_refuse_lost_rows_and_resets() { - let live = Producer::new(); + let live = Producer::new(0); let window = Duration::from_secs(30); for segment in 5..8 { live.push(row(segment, segment, segment * 2_000, 2_000), window); @@ -915,44 +771,12 @@ 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", b""), tag("video2.hls", b"")); + assert_ne!(tag("video.hls", b"a"), tag("video.hls", b"b")); + // The separator keeps a name's tail from passing for a config's head. + assert_ne!(tag("video.hls", b"x"), tag("video.hlsx", b"")); + assert_eq!(tag("video.hls", b"").len(), 8); + assert!(tag("a.b/c", b"").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 77de4f2582d..00000000000 --- a/rs/moq-hls/src/export/spans.rs +++ /dev/null @@ -1,449 +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 keyframe groups to bootstrap an inline-parameter-set codec's init from, in the order - /// to try them (see [`bootstrap_groups`]). - pub fn keyframe_groups(&self) -> Vec { - let state = self.state.read(); - bootstrap_groups( - state - .records - .iter() - .rev() - .filter(|record| starts_sync(record)) - .map(|record| record.start..record.end), - ) - } -} - -/// Order keyframe group spans, newest first, for an init bootstrap: the newest group whose span -/// already reached a later group, which is closed and so read from cache at once, then the -/// newest group when that one may still be open. -pub(crate) fn bootstrap_groups(mut spans: impl Iterator>) -> Vec { - let Some(newest) = spans.next() else { - return Vec::new(); - }; - let closed = |span: &Range| span.end.group > span.start.group; - if closed(&newest) { - return vec![newest.start.group]; - } - let older = spans.find(closed).map(|span| span.start.group); - older.into_iter().chain([newest.start.group]).collect() -} - -/// 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/import.rs b/rs/moq-hls/src/import.rs index 6cd9fa0a84d..7163d60be3c 100644 --- a/rs/moq-hls/src/import.rs +++ b/rs/moq-hls/src/import.rs @@ -654,7 +654,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 { @@ -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-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..39dc006b01d 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, @@ -20,11 +20,11 @@ //! //! `{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 +//! 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)) -//! 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 b5229f59573..4e8d34a6c2f 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. @@ -251,6 +251,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(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); @@ -273,17 +283,19 @@ mod tests { async fn wait_listed(app: &axum::Router, playlist: &str, segment: &str) { let deadline = tokio::time::Instant::now() + TIMEOUT; + let mut last = String::new(); loop { let response = oneshot(app.clone(), playlist).await; if response.status() == StatusCode::OK { let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); - if String::from_utf8_lossy(&body).contains(segment) { + last = String::from_utf8_lossy(&body).into_owned(); + if last.contains(segment) { return; } } assert!( tokio::time::Instant::now() < deadline, - "{playlist} never listed {segment}" + "{playlist} never listed {segment}: {last}" ); tokio::time::sleep(Duration::from_millis(50)).await; } @@ -299,7 +311,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 @@ -373,10 +385,10 @@ mod tests { broadcast.announce(run()).unwrap(); let secret = origin.create_broadcast("room/secret").unwrap(); secret.announce(Default::default()).unwrap(); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), Default::default()).unwrap(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), hls_config()).unwrap(); let reserved = catalog.reserve(); let mut config = video_config(); - config.broadcast = Some(moq_net::path::Relative::new("./secret").to_owned().unwrap()); + config.broadcast = Some(moq_net::path::Relative::new("./secret").unwrap()); let mut video = reserved .video( secret.create_track("video0", timed()).unwrap(), @@ -431,9 +443,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/53e0aafd.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/53e0aafd.0.m4s").await; let next = moq_net::Epoch::mint(); let new = origin.create_broadcast("live").expect("publish"); @@ -449,7 +461,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/53e0aafd.0.m4s").await; assert_eq!(status(&app, &old_playlist).await, StatusCode::OK); assert_eq!(status(&app, &old_segment).await, StatusCode::OK); @@ -466,42 +478,35 @@ 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 broadcast = origin.create_broadcast("live").expect("publish"); broadcast.announce(run()).expect("announce"); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), 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(); @@ -536,7 +541,7 @@ mod tests { wait_listed( &app, &format!("/live/@{RUN}/video/video0/media.m3u8"), - "seg/656e3d1b.0.m4s", + "seg/53e0aafd.0.m4s", ) .await; let broadcaster = server.broadcaster("live").await.expect("broadcaster"); @@ -545,7 +550,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.53e0aafd.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=\"") @@ -567,26 +572,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.53e0aafd.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.53e0aafd.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/53e0aafd.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.53e0aafd.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.53e0aafd.0.m4s")).await, StatusCode::NOT_FOUND ); } @@ -600,7 +605,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.broadcast().clone(), Default::default()).unwrap(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), hls_config()).unwrap(); let reserved = catalog.reserve(); let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("./source").unwrap()); @@ -632,8 +637,9 @@ 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 tag = |name| broadcaster.rendition(Kind::Video, name).unwrap().tag().to_string(); + let allowed_uri = &format!("/room/live/@{RUN}/video/video0/seg/{}.0.m4s", tag("video0")); + let denied_uri = &format!("/room/live/@{RUN}/video/video1/seg/{}.0.m4s", tag("video1")); let app = server.router(); assert_eq!(oneshot(app.clone(), allowed_uri).await.status(), StatusCode::OK); for uri in [ @@ -659,7 +665,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: rendition.tag().into(), at: segments::At::Sequence(0), }; let rendition = broadcaster.rendition(Kind::Video, "video1").unwrap(); @@ -679,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/656e3d1b.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 @@ -700,7 +709,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()))); diff --git a/rs/moq-hls/tests/integration/disconnect.rs b/rs/moq-hls/tests/integration/disconnect.rs index d69adb36617..f04f7c70634 100644 --- a/rs/moq-hls/tests/integration/disconnect.rs +++ b/rs/moq-hls/tests/integration/disconnect.rs @@ -16,7 +16,7 @@ enum At { /// Drop the publisher's session at `at` and check the recording ends as incomplete. /// -/// The catalog's and the timeline's subscriptions both fail with the session. Whichever reaches +/// The catalog's and the records' subscriptions both fail with the session. Whichever reaches /// the cursor first must end it with a typed error, so the order is left to the session. async fn disconnect(at: At) { let mut pair = Pair::connect().await; @@ -60,7 +60,7 @@ async fn disconnect(at: At) { matches!( err, moq_hls::Error::CatalogFailed { .. } - | moq_hls::Error::TimelineFailed { .. } + | moq_hls::Error::RecordsFailed { .. } | moq_hls::Error::SegmentUnavailable { .. } ), "{at:?}: a disconnect ended the recording with {err:?}" @@ -83,7 +83,7 @@ async fn disconnect(at: At) { assert!( matches!( refused, - moq_hls::Error::CatalogFailed { .. } | moq_hls::Error::TimelineFailed { .. } + moq_hls::Error::CatalogFailed { .. } | moq_hls::Error::RecordsFailed { .. } ), "{at:?}: {refused:?}" ); diff --git a/rs/moq-hls/tests/integration/finish.rs b/rs/moq-hls/tests/integration/finish.rs index 02dd28aa357..bd0cb1bae01 100644 --- a/rs/moq-hls/tests/integration/finish.rs +++ b/rs/moq-hls/tests/integration/finish.rs @@ -40,7 +40,7 @@ async fn finish(order: Order) { publisher.finish(); // The served playlist ends while the broadcast is still announced. let playlist = listed(&app, &media, "#EXT-X-ENDLIST").await; - assert_eq!(numbers(&playlist), [0, 1, 2, 3], "{playlist}"); + assert_eq!(numbers(&playlist), [0, 1, 2, 3, 4], "{playlist}"); publisher.broadcast.close(); } Order::EndBeforeEof => { @@ -59,10 +59,11 @@ async fn finish(order: Order) { } } - // The final GOP is promoted to the last segment, and the recording reads to its end. + // The final GOP is the last segment, the finish a trailing gap, and the recording reads to + // its end. let (read, end) = drain(&mut cursor).await; end.unwrap_or_else(|err| panic!("{order:?}: {err}")); - assert_eq!(read, [1, 2, 3], "{order:?}"); + assert_eq!(read, [1, 2, 3, 4], "{order:?}"); let again = moq_clock::timeout(TIMEOUT, cursor.next()) .await .expect("the cursor stalled"); @@ -70,7 +71,7 @@ async fn finish(order: Order) { let playlist = rendition.media_playlist(None).expect("finished").expect("listed"); assert!(playlist.ends_with("#EXT-X-ENDLIST\n"), "{order:?}: {playlist}"); - assert_eq!(numbers(&playlist), [0, 1, 2, 3], "{order:?}"); + assert_eq!(numbers(&playlist), [0, 1, 2, 3, 4], "{order:?}"); } #[tokio::test(start_paused = true)] 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", diff --git a/rs/moq-hls/tests/integration/server.rs b/rs/moq-hls/tests/integration/server.rs index be5d27abdf4..2063df6949a 100644 --- a/rs/moq-hls/tests/integration/server.rs +++ b/rs/moq-hls/tests/integration/server.rs @@ -24,7 +24,7 @@ async fn respond_serves_a_route_rewritten_into_scope() { 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(".0.m4s") { break body; } } @@ -32,7 +32,7 @@ async fn respond_serves_a_route_rewritten_into_scope() { moq_clock::sleep(std::time::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(".0.m4s?jwt=abc"), "{body}"); } /// A recorder's cursor gets the media of a publisher whose moq-lite has no FETCH: the cursor's @@ -59,7 +59,7 @@ async fn a_cursor_records_a_publisher_without_fetch() { .expect("request timed out") .expect("remote broadcast"); let broadcaster = Broadcaster::new(moq_mux::Source::new(origin, remote), Default::default()); - // Recorded as soon as the catalog lists it with its timeline, as a recorder does. + // Recorded as soon as the catalog lists it with its records, as a recorder does. let mut renditions = broadcaster.renditions(); let rendition = match moq_clock::timeout(TIMEOUT, renditions.next()).await { Ok(Ok(Some(renditions::Event::Added(rendition)))) => rendition, @@ -105,8 +105,8 @@ async fn a_session_crossed_cache_miss_answers_404() { let app = pair.server().router(); let media = media_uri(&app, "live").await; - listed(&app, &media, "seg/656e3d1b.0.m4s").await; - assert_missing(&get(&app, &media.replace("media.m3u8", "seg/656e3d1b.0.m4s")).await); + let first = segment(&listed(&app, &media, ".0.m4s").await, 0); + assert_missing(&get(&app, &media.replace("media.m3u8", &first)).await); } #[tokio::test(start_paused = true)] @@ -117,7 +117,7 @@ async fn a_disconnected_publisher_on_the_catalog_broadcast_answers_404() { let app = pair.server().router(); let media = media_uri(&app, "live").await; - listed(&app, &media, "seg/656e3d1b.0.m4s").await; + let first = segment(&listed(&app, &media, ".0.m4s").await, 0); let remote = moq_clock::timeout(TIMEOUT, pair.subscriber.consume().request_broadcast("live", None)) .await @@ -128,7 +128,7 @@ async fn a_disconnected_publisher_on_the_catalog_broadcast_answers_404() { .await .expect("publisher close timed out"); - assert_missing(&get(&app, &media.replace("media.m3u8", "seg/656e3d1b.0.m4s")).await); + assert_missing(&get(&app, &media.replace("media.m3u8", &first)).await); } /// The server watches its whole scope, so a lookup rides one request to the peer rather than @@ -201,7 +201,7 @@ async fn a_disconnected_sibling_publisher_answers_404() { media_broadcast.announce(Default::default()).expect("announce media"); let catalog = - moq_mux::catalog::Producer::new(&mut catalog_broadcast.broadcast().clone(), Default::default()).unwrap(); + moq_mux::catalog::Producer::new(&mut catalog_broadcast.broadcast().clone(), catalog_config()).unwrap(); let reserved = catalog.reserve(); let mut config = video_config(); config.broadcast = Some(moq_net::path::Relative::new("./source").unwrap()); @@ -220,7 +220,7 @@ async fn a_disconnected_sibling_publisher_answers_404() { let app = pair.server().router(); let uri = media_uri(&app, "room/live").await; - listed(&app, &uri, "seg/656e3d1b.0.m4s").await; + let first = segment(&listed(&app, &uri, ".0.m4s").await, 0); // Waited on rather than requested once: a client only asks about what it watches. let remote_media = moq_clock::timeout(TIMEOUT, pair.subscriber.consume().routed_broadcast("room/source")) @@ -234,6 +234,6 @@ async fn a_disconnected_sibling_publisher_answers_404() { .await .expect("sibling close timed out"); - assert_missing(&get(&app, &uri.replace("media.m3u8", "seg/656e3d1b.0.m4s")).await); + assert_missing(&get(&app, &uri.replace("media.m3u8", &first)).await); drop((catalog, catalog_broadcast, media)); } diff --git a/rs/moq-hls/tests/integration/support.rs b/rs/moq-hls/tests/integration/support.rs index c232fadd154..1ce48d9d523 100644 --- a/rs/moq-hls/tests/integration/support.rs +++ b/rs/moq-hls/tests/integration/support.rs @@ -87,14 +87,14 @@ fn origin(config: moq_net::origin::Config) -> moq_net::origin::Producer { origin } -/// One publisher run of a broadcast: a catalog and a single VP8 rendition whose timeline the +/// One publisher run of a broadcast: a catalog and a single VP8 rendition whose HLS records the /// catalog advertises. pub struct Publisher { /// The broadcast, which announces its path while it lives. pub broadcast: moq_net::origin::Publication, /// The catalog. pub catalog: moq_mux::catalog::Producer, - /// The media track, recording its timeline as it is written. + /// The media track, recording its HLS records as it is written. pub media: moq_mux::container::Producer, } @@ -103,10 +103,8 @@ impl Publisher { pub fn new(origin: &moq_net::origin::Producer, path: &str, route: moq_net::origin::Route) -> Self { let broadcast = origin.create_broadcast(path).expect("publish"); broadcast.announce(route).expect("announce"); - // A hinted timeline is advertised before any media, so a recorder can start first. - let timeline = moq_mux::timeline::Config::default().with_duration_hint(Duration::from_secs(2)); - let config = moq_mux::catalog::Config::default().with_timeline(timeline); - let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), config).expect("catalog"); + let catalog = + moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), catalog_config()).expect("catalog"); let reserved = catalog.reserve(); let track = broadcast.create_track(TRACK, timed()).expect("track"); let media = reserved @@ -140,6 +138,13 @@ impl Publisher { } } +/// A catalog config publishing HLS records, hinted so they are advertised before any media and a +/// recorder can start first. +pub fn catalog_config() -> moq_mux::catalog::Config { + let hls = moq_mux::hls::Config::default().with_duration_hint(Duration::from_secs(2)); + moq_mux::catalog::Config::default().with_hls(hls) +} + /// A millisecond track: the frames written here carry timestamps. pub fn timed() -> moq_net::track::Info { moq_net::track::Info::default().with_timescale(moq_net::Timescale::MILLI) @@ -259,3 +264,13 @@ pub async fn media_uri(app: &axum::Router, path: &str) -> String { .unwrap_or_else(|| panic!("no {TRACK} variant in {master}")); format!("/{path}/{line}") } + +/// The URI `playlist` lists for segment `n`. +pub fn segment(playlist: &str, n: u64) -> String { + let suffix = format!(".{n}.m4s"); + playlist + .lines() + .find(|line| line.starts_with("seg/") && line.ends_with(&suffix)) + .unwrap_or_else(|| panic!("no segment {n} in {playlist}")) + .to_string() +} diff --git a/rs/moq-mux/MIGRATE.md b/rs/moq-mux/MIGRATE.md index 50e1f68aa9b..45d078de028 100644 --- a/rs/moq-mux/MIGRATE.md +++ b/rs/moq-mux/MIGRATE.md @@ -26,6 +26,18 @@ Match `Target::Enabled(rate)` where you took the rate before. A policy without ` A TS PMT listing a PID twice routes it by the later entry instead of failing the import. Delete matches on `DuplicateTrackName`. +### 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`. +`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` `json::Config::deltas` and `with_deltas` move to the new `json::Options`, which `catalog::Producer::json_snapshot` takes as a third argument, so a custom catalog entry can set them too. @@ -129,21 +141,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 3252b6b0674..b1f74ca9ac7 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -160,40 +160,17 @@ 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, + /// 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 { @@ -203,23 +180,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(), + broadcast: self.broadcast.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, &mut config.hls)); + let audio = catalog + .audio + .renditions + .iter_mut() + .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); + } + } + 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()))?; @@ -241,13 +247,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()); @@ -275,11 +275,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 @@ -301,8 +298,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(), @@ -324,7 +320,7 @@ pub struct Config { max_age: Option, bandwidth: moq_net::bandwidth::Allocator, clock: Option, - timeline: crate::timeline::Config, + hls: Option, } impl Default for Config<()> { @@ -334,7 +330,7 @@ impl Default for Config<()> { max_age: None, bandwidth: moq_net::bandwidth::Allocator::unlimited(), clock: None, - timeline: crate::timeline::Config::default(), + hls: None, } } } @@ -350,7 +346,7 @@ impl Config { max_age: self.max_age, bandwidth: self.bandwidth, clock: self.clock, - timeline: self.timeline, + hls: self.hls, } } @@ -378,7 +374,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(); @@ -397,9 +393,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 } } @@ -412,8 +410,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; @@ -443,8 +440,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 { @@ -452,7 +448,8 @@ impl Producer { hang_track, hangz, msf_track, - catalog_timeline, + hls, + broadcast: broadcast.clone(), }, current: Arc::new(Mutex::new(State { catalog, @@ -464,8 +461,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(), @@ -549,7 +545,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 @@ -797,25 +794,29 @@ 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 a video or audio track 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)?; + // 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) @@ -837,11 +838,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)) } @@ -873,111 +871,64 @@ 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 + /// 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( - &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) + // 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)) } - /// Retire `track`'s timeline once its producer dropped, for an owner that never enrolls the - /// name again: the timeline track finishes and leaves the root `archive` entry. - pub(crate) fn retire(&mut self, track: &str) -> crate::Result<()> { - self.timeline.retire(track)?; - let advertised = take(&self.current) - .catalog - .archive - .as_ref() - .is_some_and(|archive| archive.timelines.contains_key(track)); - if advertised && let Some(archive) = self.modify()?.archive.as_mut() { - archive.timelines.remove(track); - } - Ok(()) + /// How many HLS record tracks are held, live or kept for a re-enrollment. + #[cfg(test)] + pub(crate) fn hls_tracks(&self) -> usize { + self.outputs.hls.as_ref().map_or(0, |tracks| tracks.len()) } - /// 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() + /// Retire `track`'s HLS records once its producer dropped, for an owner that never enrolls the + /// name again: the record track finishes and is forgotten. The catalog stops advertising it + /// with the rendition that carried it. + pub(crate) fn retire(&self, track: &str) -> crate::Result<()> { + match &self.outputs.hls { + Some(tracks) => tracks.retire(track), + None => Ok(()), + } } /// Publish `track` as a latest-value JSON track, advertising it in the catalog. @@ -1067,7 +1018,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() { @@ -1076,16 +1027,21 @@ impl Producer { state.closed = Some(reason.clone()); } self.outputs.abort(&reason); - self.timeline.finish(); + // The records fail too, rather than reading as a complete recording. + if let Some(hls) = &self.outputs.hls { + hls.abort(&err); + } } /// 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 { @@ -1096,7 +1052,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(()) } } @@ -1540,6 +1498,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(); @@ -1561,9 +1548,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] @@ -1571,15 +1558,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.info().await.expect("info"); @@ -1968,161 +1955,233 @@ mod test { ); } + /// A catalog publishing HLS records. + fn hls() -> Config { + Config::default().with_hls(crate::hls::Config::default()) + } + + /// 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 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 timeline_reports_a_track_collision() { + 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 catalog = Producer::new(&mut broadcast, hls()).unwrap(); - // Something else already took the name the timeline track wants. - let _taken = broadcast.create_track("video0.timeline.z", None).unwrap(); + // 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()); } - /// A retired track's timeline ends for the readers already on it and leaves the archive, while - /// one still recording is refused. #[test] - fn retiring_a_track_ends_its_timeline() { + 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 recorder = catalog - .enroll("video0", Some(std::time::Duration::from_secs(4))) - .unwrap(); - let waiter = kio::Waiter::noop(); - let mut subscribing = broadcast - .consume() - .track("video0.timeline.z") - .unwrap() - .subscribe(None) - .into_inner(); - let std::task::Poll::Ready(Ok(mut reader)) = kio::Task::poll(&mut subscribing, &waiter) else { - panic!("the timeline track is live"); - }; - assert!(catalog.retire("video0").is_err(), "its recorder is still alive"); + let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); + assert!( + catalog + .enroll("audio0", Some(std::time::Duration::from_secs(4))) + .unwrap() + .is_none() + ); - drop(recorder); - catalog.retire("video0").unwrap(); - let archive = catalog.snapshot().archive.unwrap(); - assert_eq!(archive.timelines.keys().collect::>(), vec!["catalog.json"]); - assert_eq!(catalog.timeline().len(), 1); - assert!(matches!(reader.poll_finished(&waiter), std::task::Poll::Ready(Ok(_)))); + 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 enrolling_advertises_the_catalog_section() { + #[tokio::test] + async fn an_estimated_duration_is_advertised_after_its_first_segment() { let mut broadcast = moq_net::broadcast::Info::new().produce(); - let mut catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let mut consumer = catalog.consume().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))) + 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: moq_net::Timestamp::from_millis(4_000).unwrap(), + duration: None, + payload: Bytes::from_static(b"keyframe"), + keyframe: true, + }) .unwrap(); - let archive = catalog - .snapshot() - .archive - .expect("the root archive advertises the timelines"); - assert_eq!(archive, catalog.timeline().section()); + let declared = consumer.next().await.unwrap().unwrap(); assert_eq!( - archive.timelines.keys().collect::>(), - vec!["catalog.json", "video0"], - "the catalog indexes itself alongside its first media track" + declared.video.renditions["video0"].hls, + Some(hang::catalog::Hls::new("video0.hls", std::time::Duration::from_secs(4))) ); } #[test] - fn an_estimated_timeline_is_advertised_after_its_first_record() { + fn a_track_finished_before_its_first_segment_is_advertised() { 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 catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let mut video = video0(&mut broadcast, &catalog, &[0]); - let initial = catalog.snapshot().archive.expect("the catalog timeline is advertised"); - assert!(!initial.timelines.contains_key("video0")); + video.finish().unwrap(); + let hls = catalog.snapshot().video.renditions["video0"].hls.clone(); + assert_eq!(hls.unwrap().duration, crate::hls::DEFAULT_DURATION); + } + + #[test] + fn a_track_hint_overrides_the_configured_one() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let config = Config::default() + .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(); - recorder.frame( - hang::timeline::Position::group(0), - moq_net::Timestamp::from_millis(0).unwrap(), - true, + let tracks = catalog.outputs.hls.as_ref().unwrap(); + assert_eq!( + tracks.hls("video0").unwrap().duration, + std::time::Duration::from_secs(6) ); - recorder.finish_group(0); - recorder.frame( - hang::timeline::Position::group(1), - moq_net::Timestamp::from_millis(4_000).unwrap(), - true, + assert_eq!( + tracks.hls("audio0").unwrap().duration, + std::time::Duration::from_secs(5) ); - - let archive = catalog.snapshot().archive.expect("the estimate publishes the timeline"); - assert_eq!(archive.timelines["video0"].duration, Some(4_000)); } #[test] - fn a_track_shorter_than_one_record_is_advertised_on_finish() { + fn only_the_enrolled_rendition_gets_its_records() { 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, hls()).unwrap(); + let _video = catalog + .enroll("video", Some(std::time::Duration::from_secs(2))) + .unwrap(); - catalog.finish().unwrap(); - let archive = catalog.snapshot().archive.expect("the catalog timeline is advertised"); - assert_eq!(archive.timelines["audio0"].duration, Some(4_000)); + // 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").unwrap()); + 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] + fn retiring_a_track_ends_its_records() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); + let recorder = catalog + .enroll("video0", Some(std::time::Duration::from_secs(4))) + .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"); + }; + assert!(catalog.retire("video0").is_err(), "its recorder is still alive"); + + drop(recorder); + catalog.retire("video0").unwrap(); + assert_eq!(catalog.hls_tracks(), 0); + assert!(matches!(reader.poll_finished(&waiter), std::task::Poll::Ready(Ok(_)))); } #[test] - fn a_track_finished_before_its_catalog_is_advertised() { + fn a_text_track_neither_records_nor_paces() { let mut broadcast = moq_net::broadcast::Info::new().produce(); - let catalog = Producer::new(&mut broadcast, Config::default()).unwrap(); + let catalog = Producer::new(&mut broadcast, hls()).unwrap(); let net = broadcast - .create_track("video0", catalog.track_info(hang::catalog::PRIORITY.video)) + .create_track("text0", catalog.track_info(hang::catalog::PRIORITY.text)) .unwrap(); - let mut video = catalog - .video( + let _text = catalog + .text( net, - crate::catalog::hang::Container::Legacy(crate::container::Kind::Video), - VideoConfig::new(hang::catalog::VideoCodec::VP8), + crate::catalog::hang::Container::Legacy(crate::container::Kind::Data), + hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt), ) .unwrap(); - video - .write(crate::container::Frame { - timestamp: catalog.timestamp(None).unwrap(), - duration: None, - payload: Bytes::from_static(b"keyframe"), - keyframe: true, - }) - .unwrap(); + assert_eq!(catalog.hls_tracks(), 0); - video.finish().unwrap(); - let archive = catalog.snapshot().archive.expect("the catalog timeline is advertised"); - assert_eq!(archive.timelines["video0"].duration, Some(4_000)); + // 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 a_configured_hint_applies_to_media_but_not_the_catalog() { + fn an_undeclared_enrollment_releases_the_broadcast() { 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(); - let _video = catalog.enroll("video0", None).unwrap(); - let _audio = catalog - .enroll("audio0", Some(std::time::Duration::from_secs(5))) - .unwrap(); + 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"); + }; - 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)); + // 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_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 b6765917ed2..8c9517491af 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/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/import.rs b/rs/moq-mux/src/container/fmp4/import.rs index 534c1e23f6b..f74510af98d 100644 --- a/rs/moq-mux/src/container/fmp4/import.rs +++ b/rs/moq-mux/src/container/fmp4/import.rs @@ -84,7 +84,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, } @@ -156,9 +156,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, @@ -189,9 +190,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(()) } @@ -270,7 +268,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. /// @@ -470,9 +468,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 @@ -498,7 +495,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()), @@ -938,7 +935,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; @@ -994,7 +991,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, }; @@ -1019,7 +1016,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, } @@ -1255,9 +1252,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 })?, @@ -1267,10 +1261,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() { @@ -1287,15 +1281,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); @@ -1374,7 +1367,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 84dbc6970dc..52624e2df23 100644 --- a/rs/moq-mux/src/container/fmp4/import_test.rs +++ b/rs/moq-mux/src/container/fmp4/import_test.rs @@ -1414,44 +1414,44 @@ 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"); @@ -1466,16 +1466,22 @@ 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"); @@ -1791,8 +1797,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() { @@ -1806,18 +1812,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; @@ -1852,8 +1857,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)`. @@ -1862,16 +1867,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; @@ -2301,7 +2305,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, @@ -2313,14 +2317,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; @@ -2337,9 +2340,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(); @@ -2355,7 +2358,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; @@ -2367,7 +2370,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/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index e107c9c0aab..1f6983f3f56 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; @@ -11,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}; @@ -287,10 +289,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 +325,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() + // 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); + } infer_missing_durations(&mut frames, None, self.default_frame, self.timescale)?; Ok(frames) } @@ -346,8 +361,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 +402,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 +440,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 +505,49 @@ 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)]); + } + + /// 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] @@ -507,7 +563,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 +584,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 +624,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 +643,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 +673,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 +687,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 +697,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 +706,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 +723,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 +748,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 +832,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( diff --git a/rs/moq-mux/src/container/mkv/import_test.rs b/rs/moq-mux/src/container/mkv/import_test.rs index c62757a57fd..01c3fe792b6 100644 --- a/rs/moq-mux/src/container/mkv/import_test.rs +++ b/rs/moq-mux/src/container/mkv/import_test.rs @@ -958,8 +958,8 @@ fn unsupported_audio_codec_is_refused() { /// 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] @@ -974,17 +974,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 c5e7e98a521..b53ba1729be 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,54 @@ 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 +1560,16 @@ 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 496adde0b1b..edccc4468f5 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -6420,6 +6420,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 { @@ -6476,6 +6478,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/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index 4e9bc23fdbd..91d8298ea64 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -10968,7 +10968,7 @@ pub(super) mod test { /// A source revising its PMT without end holds only its live routes: each revision ends a /// decoded or verbatim route, refuses one for a lost PCR, or rewrites the program descriptors, - /// and none leaves its track's timeline or catalog entry behind. + /// and none leaves its track's HLS records or catalog entry behind. #[test] fn pmt_churn_keeps_flat_state() { use crate::container::ts::psi::{Es, Pmt}; @@ -10977,7 +10977,9 @@ pub(super) mod test { let mut broadcast = moq_net::broadcast::Info::new().produce(); let catalog = crate::catalog::Producer::new( &mut broadcast, - crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::::default()), + crate::catalog::Config::default() + .with_catalog(crate::catalog::hang::Catalog::::default()) + .with_hls(crate::hls::Config::default()), ) .unwrap(); let mut import = super::Import::new(broadcast, catalog.reserve()); @@ -11005,8 +11007,7 @@ pub(super) mod test { let state = |import: &super::Import| { let snapshot = catalog.snapshot(); ( - catalog.timeline().len(), - snapshot.archive.map_or(0, |archive| archive.timelines.len()), + catalog.hls_tracks(), snapshot.ext.mpegts.tracks.len(), import.streams.len() + import.sections.len() + import.listed.len() + import.retired_stats.len(), ) diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 37b147d9b1a..aeaba4c0537 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. @@ -342,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 new file mode 100644 index 00000000000..970d8f80820 --- /dev/null +++ b/rs/moq-mux/src/hls.rs @@ -0,0 +1,1140 @@ +//! 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. 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. +//! +//! ## 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`] (4 seconds). +pub const DEFAULT_DURATION: Duration = Duration::from_secs(4); + +/// 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 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 +/// 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; + +/// How a broadcast's HLS segments are cut. +/// +/// 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 spacing of the pts grid segments are cut on. + /// + /// 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`](Self::duration). + pub fn with_duration(mut self, duration: Duration) -> Self { + self.duration = 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: DEFAULT_DURATION, + 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)) +} + +/// 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, + /// 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. + 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, + /// Whether boundaries come only from cuts: the source declares them. + manual: bool, + ready: VecDeque, + 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.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, + manual: false, + ready: VecDeque::new(), + closed: false, + } + } + + /// 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 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)) + } + + /// 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`. + /// + /// 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.begin(group, pts); + } + self.last = Some(group); + 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.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.begin(group, pts); + return; + } + let cell = self.cell(pts); + let elapsed = elapsed(start, pts); + 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); + } + 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.sequence += 1; + } + + /// 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() + .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 && cell > self.cell + } + + /// 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.advance(pts); + } + } + + /// Content in the open segment reached `pts`. + fn advance(&mut self, pts: Timestamp) { + if self.open.is_some() + && 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 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; + } + self.manual = true; + if self.cut.is_some_and(|cut| pts.as_micros() <= cut.as_micros()) { + return; + } + self.cut = Some(pts); + 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 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.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; + } +} + +/// 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, 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 struct Producer { + sink: moq_json::window::Producer, + /// The record track, to abort. + track: moq_net::track::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.clone(), config), + track, + } + } + + /// 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) + } + + /// 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 + /// 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, +} + +/// 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 { + declare: Option<(OnDeclare, Duration)>, +} + +impl Published { + /// Announce any declaration. + fn fire(self) { + if let Some((on_declare, duration)) = self.declare { + on_declare(duration); + } + } +} + +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; 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() { + Some(duration) => self.on_declare.take().map(|on_declare| (on_declare, duration)), + None => None, + }; + Published { declare } + } + + /// 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; + 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>)>, + 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(), + finished: false, + })), + } + } + + /// Enroll the track `name`, its segments cut by `config`. + /// + /// 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()); + } + let segmenter = |sequence: u64| Segmenter::new(config.clone()).with_sequence(sequence); + + 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; + 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, + })); + state + .tracks + .insert(name.to_string(), (hang::hls::default_name(name), live.clone())); + live + } + }; + Ok(Recorder { live }) + } + + /// 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(); + } + } + + /// Finish the record track of `name`, whose recorder dropped and whose owner never enrolls the + /// name again, and forget it, so a producer minting a name per track holds only its live ones. + /// + /// Readers already on the record track read it to its end. Nothing happens for a name never + /// enrolled; a name whose recorder is still alive errors. + pub fn retire(&self, name: &str) -> crate::Result<()> { + let mut state = self.state.lock().unwrap(); + let Some((_, live)) = state.tracks.get(name) else { + return Ok(()); + }; + let mut live = live.lock().unwrap(); + if live.recording { + return Err(moq_net::Error::Duplicate.into()); + } + live.finish(); + drop(live); + state.tracks.remove(name); + Ok(()) + } + + /// How many record tracks are held, live or kept for a re-enrollment. + #[cfg(test)] + pub fn len(&self) -> usize { + 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(); + 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 { + live: 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 Live)) { + let published = { + let mut live = self.live.lock().unwrap(); + f(&mut live); + live.publish() + }; + 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) { + 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()); + } + }); + } + + /// Report that the content extends to `pts`; see [`Segmenter::end`]. + pub fn end(&mut self, pts: Timestamp) { + 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(|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(|live| live.segmenter.cut(pts)); + } +} + +impl Drop for Recorder { + fn drop(&mut self) { + self.report(|live| { + if let Some(last) = live.segmenter.last { + live.segmenter.stop(last + 1); + } + live.recording = false; + }); + // 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; + } +} + +/// 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_into_a_grid_cell() { + 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_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_grid() { + let mut segmenter = Segmenter::new(Config::default()); + groups(&mut segmenter, 0..2, 1_000); + segmenter.close(); + assert_eq!(segmenter.declared(), Some(DEFAULT_DURATION)); + 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_segment_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_grid() { + 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 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 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))); + } + + // 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 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, late)); + 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 + }; + assert_eq!( + read("video").await, + [(0, 0, false), (1, 4_000, false), (2, 8_000, false), (3, 8_000, true)] + ); + assert_eq!( + read("audio").await, + [(0, 5, false), (1, 4_005, false), (2, 8_005, false), (3, 8_985, true)] + ); + assert_eq!( + read("late").await, + [(0, 5_005, false), (1, 8_005, false), (2, 8_985, true)] + ); + } + + #[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; + } + // 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] + 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..16c1a2a6291 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 @@ -43,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; @@ -52,7 +51,6 @@ pub mod rate; pub mod select; mod source; mod stats; -pub mod timeline; 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 7771c93d66a..00000000000 --- a/rs/moq-mux/src/timeline.rs +++ /dev/null @@ -1,1534 +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, - /// The window's track, kept to abort it with the cause of a failure. - track: moq_net::track::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.clone(), Self::config()), - track, - } - } - - /// Abort the timeline track with `err`, so its readers see a failure rather than an end. - fn abort(self, err: &crate::Error) { - // A cause outside moq-net has no wire code, so readers get a generic failure. - let reason = match err { - crate::Error::Moq(err) => err.clone(), - _ => moq_net::StreamError::Internal.into(), - }; - // A track that already closed keeps its first reason. - let _ = self.track.abort(reason); - } - - 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; aborting the timeline track"); - if let Some(output) = self.output.take() { - output.abort(&err); - } - } - } - 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, until - /// [`Timelines::retire`] says the name is done. - 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 - } - - /// Finish the timeline of `name`, whose recorder dropped and whose owner never enrolls the - /// name again, and forget it, so a producer minting a name per track holds only its live ones. - /// - /// Readers already on the timeline track read it to its end. Nothing happens for a name never - /// enrolled; a name whose recorder is still alive errors. - pub(crate) fn retire(&self, name: &str) -> crate::Result<()> { - let mut registry = self.registry.lock().unwrap(); - let Some((_, live)) = registry.tracks.get(name) else { - return Ok(()); - }; - let mut live = live.lock().unwrap(); - if live.recording { - return Err(moq_net::Error::Duplicate.into()); - } - // The catalog stops advertising the timeline, so a last declaration must not add it back. - live.on_declare = None; - live.finish(); - drop(live); - registry.tracks.remove(name); - Ok(()) - } - - /// How many timelines are held, live or kept for a re-enrollment. - #[cfg(test)] - pub(crate) fn len(&self) -> usize { - self.registry.lock().unwrap().tracks.len() - } - - /// 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; aborting the timeline track"); - live.segmenter.close(); - // Subscribers see the timeline fail rather than end. - if let Some(output) = live.output.take() { - output.abort(&err); - } - } - 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"), - // Aborted with the failure, not left to read as a producer that went away. - Err(err) => { - assert!( - matches!( - err, - crate::Error::Json(moq_json::Error::Net(moq_net::Error::Stream( - moq_net::StreamError::Internal - ))) - ), - "{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..4ffae286a2c 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,10 @@ 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-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index d2665a04a00..47797713f58 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -423,9 +423,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(); diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 523081cc31f..9575d64932b 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(Relative::new("./recordings/clip").unwrap()); - 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/MIGRATE.md b/rs/moq-video/MIGRATE.md index e9d0f43619d..5ec8794dfec 100644 --- a/rs/moq-video/MIGRATE.md +++ b/rs/moq-video/MIGRATE.md @@ -61,8 +61,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 88166968f58..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_timeline_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/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 244ad95ebff..be39b6aa4a8 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. @@ -496,7 +496,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! { @@ -881,9 +881,7 @@ mod tests { let mut producer = Producer::new( broadcast, - catalog - .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -954,9 +952,7 @@ mod tests { config.kind = encoder::Kind::Software; let mut producer = Producer::with_track( track, - catalog - .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -986,9 +982,7 @@ mod tests { initial.kind = encoder::Kind::Software; let mut producer = Producer::new( broadcast, - catalog - .reserve() - .with_timeline_hint(initial.gop.duration(initial.framerate)), + catalog.reserve().with_hls_hint(initial.gop.duration(initial.framerate)), initial.probe().await.unwrap(), ) .unwrap(); @@ -1059,9 +1053,7 @@ mod tests { config.kind = encoder::Kind::Software; let _producer = Producer::new( broadcast, - catalog - .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -1107,9 +1099,7 @@ mod tests { let mut subscriber = track.subscribe(None); let mut producer = Producer::with_track( track, - catalog - .reserve() - .with_timeline_hint(config.gop.duration(config.framerate)), + catalog.reserve().with_hls_hint(config.gop.duration(config.framerate)), config.probe().await.unwrap(), ) .unwrap(); @@ -1204,7 +1194,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( @@ -1217,9 +1208,7 @@ mod tests { probe.kind = encoder::Kind::Software; let mut producer = Producer::with_track( track, - catalog - .reserve() - .with_timeline_hint(probe.gop.duration(probe.framerate)), + catalog.reserve().with_hls_hint(probe.gop.duration(probe.framerate)), probe.probe().await.unwrap(), ) .unwrap(); @@ -1474,22 +1463,21 @@ 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 { @@ -1501,29 +1489,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 + 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 @@ -1863,7 +1852,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();