open-standards/wai/wai-rs/src/hls_render.rs
2026-10-06 10:56:35 -04:00

389 lines
16 KiB
Rust

//! HLS media playlist text (RFC 8216, and the second edition's partial segments): a
//! pure function of the ordered segments and the rendering parameters, so anyone
//! holding a playlist's bytes recomputes them, and a receipt over the bytes
//! (`wai.video.manifest`, http-delivery §8.6) binds every line.
//!
//! [`render_hls`] is the original rendering of `wai.video.manifest`: version 6, media
//! sequence 0, a derived target duration, a discontinuity before each ad and
//! `EXT-X-ENDLIST`. [`render_hls_with`] adds what an on-demand, live or low-latency WAI
//! playlist needs (http-delivery §10.4, §11): a media sequence and discontinuity
//! sequence, a fixed target duration, partial segments and their server control,
//! discontinuities and program date-times, `#WAI-LAYER` lines and a preload hint. With
//! [`HlsRender::legacy`] and no extras it gives [`render_hls`]'s bytes exactly.
//!
//! Pure text, no dependency: `http_binding` renders WAI playlists with it, and
//! `wai_video_manifest` seals them.
/// Whether a segment carries programme content or a stitched ad.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SegmentKind {
Content,
Ad,
}
impl SegmentKind {
/// `content` or `ad`.
pub fn label(self) -> &'static str {
match self {
SegmentKind::Content => "content",
SegmentKind::Ad => "ad",
}
}
}
/// One entry in the ordered manifest composition.
///
/// `ad_id.is_some()` iff `kind == Ad`; a manifest receipt's `verify` rejects any other
/// combination, so the monetisation audit (the ad-bearing segments) is a single source
/// of truth rather than a second list that could disagree.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Segment {
pub index: u64,
pub kind: SegmentKind,
/// The segment URI as it appears in the manifest (content or ad).
pub uri: String,
pub duration_ms: u64,
/// Present exactly for ad segments — the ad decision this break resolved to.
pub ad_id: Option<String>,
}
/// `EXT-X-PLAYLIST-TYPE`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlaylistType {
Vod,
Event,
}
impl PlaylistType {
/// `VOD` or `EVENT`.
pub fn label(self) -> &'static str {
match self {
PlaylistType::Vod => "VOD",
PlaylistType::Event => "EVENT",
}
}
}
/// The playlist-wide rendering parameters (the header lines and the end).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HlsRender {
/// `EXT-X-VERSION`.
pub version: u8,
/// `EXT-X-MEDIA-SEQUENCE`.
pub media_sequence: u64,
/// `EXT-X-DISCONTINUITY-SEQUENCE`, written only when it is not 0.
pub discontinuity_sequence: u64,
/// `EXT-X-TARGETDURATION` in seconds; `None` derives it as the longest segment's
/// duration rounded up. A live playlist fixes it: it must not change between
/// revisions.
pub target_duration_s: Option<u64>,
/// `EXT-X-PART-INF:PART-TARGET`, in milliseconds.
pub part_target_ms: Option<u64>,
/// `EXT-X-SERVER-CONTROL:PART-HOLD-BACK`, in milliseconds: required with a part
/// target, and at least twice it.
pub part_hold_back_ms: Option<u64>,
/// `EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES`.
pub can_block_reload: bool,
/// `EXT-X-ENDLIST`.
pub end_list: bool,
/// `EXT-X-PLAYLIST-TYPE`.
pub playlist_type: Option<PlaylistType>,
/// `EXT-X-INDEPENDENT-SEGMENTS`: every segment decodes without the one before it.
pub independent_segments: bool,
/// `EXT-X-MAP:URI`, once, before the first segment: a classical playlist's
/// initialization section (a WAI playlist has none).
pub map_uri: Option<String>,
}
impl HlsRender {
/// [`render_hls`]'s parameters: version 6, media sequence 0, a derived target
/// duration, no parts, and the end of the list.
pub fn legacy() -> Self {
HlsRender {
version: 6,
media_sequence: 0,
discontinuity_sequence: 0,
target_duration_s: None,
part_target_ms: None,
part_hold_back_ms: None,
can_block_reload: false,
end_list: true,
playlist_type: None,
independent_segments: false,
map_uri: None,
}
}
}
/// A `#WAI-LAYER` line (http-delivery §10.4): the layer object a client may fetch to
/// depth `n` over the segment or part it follows. `n` is a locator only; the
/// envelope's `refines.layer` decides.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LayerLine {
pub n: u8,
pub uri: String,
}
/// An `EXT-X-PART` line and the layer lines that follow it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PartLine {
pub duration_ms: u64,
pub independent: bool,
pub uri: String,
pub layers: Vec<LayerLine>,
}
/// What a rendering adds to one segment: a discontinuity, a program date-time, the
/// partial segments that precede its URI, a gap, the layer lines after its `EXTINF`, and
/// a preload hint (only on the playlist's last entry).
///
/// [`render_hls_with`] takes no extras, one per segment, or one per segment and one
/// more: the **open** segment at a live edge, whose parts are listed and whose
/// `EXTINF` and URI are not yet (it carries no layer lines of its own).
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct SegmentExtra {
pub discontinuity: bool,
/// `EXT-X-GAP`: the segment is absent here, and a sink takes the adjacent classical
/// segment (a class that takes the piece as floor only).
pub gap: bool,
pub program_date_time: Option<String>,
pub parts: Vec<PartLine>,
pub layers: Vec<LayerLine>,
pub preload_hint: Option<String>,
}
/// Why a rendering's inputs are refused (`playlist_malformed`, http-delivery §13): the
/// text would not read back as the lines it was given.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenderError(pub String);
impl RenderError {
/// `playlist_malformed`.
pub fn code(&self) -> &'static str {
"playlist_malformed"
}
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "playlist_malformed: {}", self.0)
}
}
impl std::error::Error for RenderError {}
/// Milliseconds as a decimal number of seconds with three fractional digits, the same
/// on every platform.
pub fn seconds(ms: u64) -> String {
format!("{}.{:03}", ms / 1000, ms % 1000)
}
/// Deterministic HLS rendering: the manifest is a pure function of the ordered
/// segments, so `hash(render(segments))` is a stable identity a verifier can
/// recompute. Ad segments carry an `EXT-X-DISCONTINUITY` — the real SSAI splice
/// marker — so the stitched breaks are visible in the bytes, not only the
/// receipt. Target duration is derived (ceil of the longest segment) rather than
/// supplied, so nothing outside `segments` can move the output.
pub fn render_hls(segments: &[Segment]) -> String {
render_hls_with(segments, &HlsRender::legacy(), &[])
}
fn layer_lines(out: &mut String, layers: &[LayerLine]) {
for l in layers {
out.push_str(&format!("#WAI-LAYER:N={},URI=\"{}\"\n", l.n, l.uri));
}
}
fn part_lines(out: &mut String, parts: &[PartLine]) {
for p in parts {
out.push_str(&format!("#EXT-X-PART:DURATION={}", seconds(p.duration_ms)));
if p.independent {
out.push_str(",INDEPENDENT=YES");
}
out.push_str(&format!(",URI=\"{}\"\n", p.uri));
layer_lines(out, &p.layers);
}
}
/// The playlist `segments` render to under `r`, with `extras` (none, one per segment,
/// or one per segment and the open segment's). Each segment is written as: its
/// discontinuity (an ad's, or its extra's; one line), its program date-time, its
/// parts (each `EXT-X-PART` followed by its layer lines), its `EXT-X-GAP`, its
/// `EXTINF`, its layer lines, and its URI. The open segment follows with its
/// discontinuity, program date-time and parts; then the preload hint and
/// `EXT-X-ENDLIST`. The header is `EXTM3U`, `EXT-X-VERSION`, `EXT-X-TARGETDURATION`,
/// `EXT-X-PART-INF`, `EXT-X-SERVER-CONTROL`, `EXT-X-MEDIA-SEQUENCE`, and, those that
/// apply, `EXT-X-DISCONTINUITY-SEQUENCE` (when not 0), `EXT-X-PLAYLIST-TYPE`,
/// `EXT-X-INDEPENDENT-SEGMENTS` and `EXT-X-MAP`. Durations have three fractional digits.
///
/// Rendering never fails; [`check_render`] says whether the inputs read back as
/// themselves, and a WAI playlist is published or sealed only when they do.
pub fn render_hls_with(segments: &[Segment], r: &HlsRender, extras: &[SegmentExtra]) -> String {
let target = r.target_duration_s.unwrap_or_else(|| segments.iter().map(|s| s.duration_ms.div_ceil(1000)).max().unwrap_or(0));
let mut out = String::new();
out.push_str(&format!("#EXTM3U\n#EXT-X-VERSION:{}\n", r.version));
out.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n"));
if let Some(pt) = r.part_target_ms {
out.push_str(&format!("#EXT-X-PART-INF:PART-TARGET={}\n", seconds(pt)));
}
let mut control: Vec<String> = Vec::new();
if r.can_block_reload {
control.push("CAN-BLOCK-RELOAD=YES".into());
}
if let Some(h) = r.part_hold_back_ms {
control.push(format!("PART-HOLD-BACK={}", seconds(h)));
}
if !control.is_empty() {
out.push_str(&format!("#EXT-X-SERVER-CONTROL:{}\n", control.join(",")));
}
out.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{}\n", r.media_sequence));
if r.discontinuity_sequence != 0 {
out.push_str(&format!("#EXT-X-DISCONTINUITY-SEQUENCE:{}\n", r.discontinuity_sequence));
}
if let Some(t) = r.playlist_type {
out.push_str(&format!("#EXT-X-PLAYLIST-TYPE:{}\n", t.label()));
}
if r.independent_segments {
out.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
}
if let Some(m) = &r.map_uri {
out.push_str(&format!("#EXT-X-MAP:URI=\"{m}\"\n"));
}
let none = SegmentExtra::default();
for (i, s) in segments.iter().enumerate() {
let e = extras.get(i).unwrap_or(&none);
if s.kind == SegmentKind::Ad || e.discontinuity {
out.push_str("#EXT-X-DISCONTINUITY\n");
}
if let Some(pdt) = &e.program_date_time {
out.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
}
part_lines(&mut out, &e.parts);
if e.gap {
out.push_str("#EXT-X-GAP\n");
}
// Fixed 3-dp seconds so the byte output is stable across platforms.
out.push_str(&format!("#EXTINF:{},\n", seconds(s.duration_ms)));
layer_lines(&mut out, &e.layers);
out.push_str(&s.uri);
out.push('\n');
}
if let Some(open) = extras.get(segments.len()) {
if open.discontinuity {
out.push_str("#EXT-X-DISCONTINUITY\n");
}
if let Some(pdt) = &open.program_date_time {
out.push_str(&format!("#EXT-X-PROGRAM-DATE-TIME:{pdt}\n"));
}
part_lines(&mut out, &open.parts);
}
if let Some(hint) = extras.last().and_then(|e| e.preload_hint.as_ref()) {
out.push_str(&format!("#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"{hint}\"\n"));
}
if r.end_list {
out.push_str("#EXT-X-ENDLIST\n");
}
out
}
fn uri_ok(u: &str, quoted: bool) -> bool {
!u.is_empty() && !u.starts_with('#') && !u.bytes().any(|b| b == b'\r' || b == b'\n' || (quoted && b == b'"'))
}
fn check_layers(layers: &[LayerLine], at: &str) -> Result<(), RenderError> {
let mut last = 0u8;
for l in layers {
if l.n == 0 || l.n <= last {
return Err(RenderError(format!("{at}: layer numbers are 1 to 255 and strictly increasing")));
}
if !uri_ok(&l.uri, true) {
return Err(RenderError(format!("{at}: a layer URI is empty or holds a quote, CR or LF")));
}
last = l.n;
}
Ok(())
}
/// Whether `segments`, `r` and `extras` render to a playlist that reads back as them
/// (`playlist_malformed` otherwise):
///
/// - `extras` is empty, one per segment, or one more (the open segment, which carries
/// no layer lines, needs a part before a discontinuity or a date-time, and is not
/// in a playlist that ends);
/// - a preload hint is on the last extra only, and not in a playlist that ends;
/// - parts need a part target, and a part target needs a part hold-back of at least
/// twice it (the HLS second edition requires both);
/// - every URI is non-empty, does not begin with `#`, and holds no CR or LF, and a
/// quoted one (a part's, a layer's, a hint's, the map's) no `"`;
/// - the open segment has no gap, and a gap segment carries no parts or layer lines
/// (it offers no object there, so nothing refines or divides one);
/// - a program date-time is ASCII letters, digits and `:.+-` only;
/// - layer numbers are 1 to 255 and strictly increasing after each segment or part;
/// - the version is at least 1.
pub fn check_render(segments: &[Segment], r: &HlsRender, extras: &[SegmentExtra]) -> Result<(), RenderError> {
let n = segments.len();
if !(extras.is_empty() || extras.len() == n || extras.len() == n + 1) {
return Err(RenderError(format!("{} extras for {n} segments", extras.len())));
}
if r.version == 0 {
return Err(RenderError("version 0".into()));
}
if r.map_uri.as_deref().is_some_and(|m| !uri_ok(m, true)) {
return Err(RenderError("the map URI is empty or holds a quote, CR or LF".into()));
}
if let Some(pt) = r.part_target_ms {
match r.part_hold_back_ms {
Some(h) if pt > 0 && h >= pt.saturating_mul(2) => {}
_ => return Err(RenderError("a part target needs a part hold-back of at least twice it".into())),
}
}
for (i, s) in segments.iter().enumerate() {
if !uri_ok(&s.uri, false) {
return Err(RenderError(format!("segment {i}: its URI is empty, begins with # or holds CR or LF")));
}
}
for (i, e) in extras.iter().enumerate() {
let at = if i == n { "the open segment".to_string() } else { format!("segment {i}") };
if let Some(p) = &e.program_date_time {
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b":.+-".contains(&b)) {
return Err(RenderError(format!("{at}: a program date-time outside letters, digits and :.+-")));
}
}
if !e.parts.is_empty() && r.part_target_ms.is_none() {
return Err(RenderError(format!("{at}: parts with no part target")));
}
for p in &e.parts {
if !uri_ok(&p.uri, true) {
return Err(RenderError(format!("{at}: a part URI is empty or holds a quote, CR or LF")));
}
check_layers(&p.layers, &at)?;
}
check_layers(&e.layers, &at)?;
if i < n && e.gap && (!e.parts.is_empty() || !e.layers.is_empty()) {
return Err(RenderError(format!("{at}: a gap carries parts or layer lines")));
}
if let Some(h) = &e.preload_hint {
if i + 1 != extras.len() || r.end_list {
return Err(RenderError(format!("{at}: a preload hint is on the last entry of a playlist that does not end")));
}
if !uri_ok(h, true) {
return Err(RenderError(format!("{at}: the hint URI is empty or holds a quote, CR or LF")));
}
}
if i == n {
if r.end_list {
return Err(RenderError("an open segment in a playlist that ends".into()));
}
if !e.layers.is_empty() || e.gap {
return Err(RenderError("the open segment carries layer lines or a gap of its own".into()));
}
if e.parts.is_empty() && (e.discontinuity || e.program_date_time.is_some()) {
return Err(RenderError("the open segment's discontinuity or date-time comes before a part".into()));
}
if e.parts.is_empty() && e.preload_hint.is_none() {
return Err(RenderError("an open segment with no part and no hint".into()));
}
}
}
Ok(())
}