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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions drafts/draft-lcurley-moq-hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,17 @@ For example:
- A fragment or packet batch contributes the media span between its earliest timestamp and flush point.
- Reordered frames contribute the delay they were held before flushing, without treating a decode-order presentation timestamp gap as delay by itself.

### warmup {#field-warmup}

An audio or video rendition MAY declare `warmup`, an integer duration in milliseconds after a group start before its decoded output is presentable without decoding the previous group.
A publisher MUST round a fractional-millisecond warmup up to the next whole millisecond.
Absent or `0` means every group start is immediately presentable.
After a non-continuous join, a consumer MUST decode from the group start and MUST NOT present frames stamped before the group's start plus `warmup`.
It SHOULD join that much earlier to obtain the requested presentation time.

For video, `warmup` can describe a refresh cycle during which decoding converges without an initial keyframe.
For audio, it describes decoder pre-roll, for example 80 milliseconds for Opus, and does not change the group-start rule.

### delay {#field-delay}
The maximum amount, in milliseconds, by which a rendition's minimum flush lateness ({{field-jitter}}) has trailed the smallest minimum among the broadcast's renditions that measure it.
If absent, a consumer SHOULD assume the rendition does not trail the others.
Expand Down Expand Up @@ -571,9 +582,11 @@ type Container =
The `kind` field selects the framing; a consumer MUST ignore a rendition whose `kind` it does not recognize.
Every container shares the same group rules:

Each moq-lite group MUST start with a keyframe, except a group that contains only a discontinuity marker.
A video group MUST start at a random access point: a keyframe unless the rendition declares `warmup` ({{field-warmup}}), in which case it MAY start at a picture from which decoding converges within that duration.
Other groups MUST start with a keyframe.
A group containing only a discontinuity marker is exempt from these rules.
If the codec does not support delta frames (e.g. audio), a group MAY consist of multiple keyframes.
Otherwise, a group MUST consist of a single keyframe followed by zero or more delta frames.
Otherwise, a group MUST consist of its random access picture followed by zero or more delta frames.

A group with no decodable frames is a walk-now discontinuity: one empty codec payload and no media.
A consumer MUST NOT submit the marker to a decoder.
Expand Down Expand Up @@ -1074,6 +1087,7 @@ This document has no IANA actions.
## moq-hang-04
{:numbered="false"}

- Added optional audio and video `warmup` durations, rounded up to whole milliseconds, and allowed video groups to start at convergent random access pictures.
- Defined encoder `jitter` as flush lateness above the rendition's own recent minimum, replacing fixed frame-duration hints; container batches retain media-span estimates.
- 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.
Expand Down
13 changes: 13 additions & 0 deletions js/hang/src/catalog/audio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,16 @@ test("audio config accepts optional enabled state", () => {

expect(config.enabled).toBe(false);
});

test("audio warmup round trips as optional integer milliseconds", () => {
const base = { codec: "opus", container: { kind: "legacy" }, sampleRate: 48_000, numberOfChannels: 2 };
expect(AudioConfigSchema.parse(base).warmup).toBeUndefined();
for (const warmup of [0, 80, 1_000]) {
const config = AudioConfigSchema.parse({ ...base, warmup });
expect(Number(config.warmup)).toBe(warmup);
expect(AudioConfigSchema.parse(JSON.parse(JSON.stringify(config)))).toEqual(config);
}
for (const warmup of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) {
expect(() => AudioConfigSchema.parse({ ...base, warmup })).toThrow();
}
});
4 changes: 4 additions & 0 deletions js/hang/src/catalog/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export const AudioConfigSchema = z.object({
),
),

// After a non-continuous join, decode from the group start, present at start plus warmup,
// and join that many milliseconds earlier.
warmup: z.optional(u53Schema),

// How far this rendition's frames reach the transport behind the broadcast's earliest
// rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never
// subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only
Expand Down
13 changes: 13 additions & 0 deletions js/hang/src/catalog/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,16 @@ test("legacy video arrays derive display size from display aspect fields", () =>
expect(Number(parsed.renditions.video?.displayAspectWidth)).toBe(16);
expect(Number(parsed.renditions.video?.displayAspectHeight)).toBe(9);
});

test("video warmup round trips as optional integer milliseconds", () => {
const base = { codec: "vp8", container: { kind: "legacy" } };
expect(VideoConfigSchema.parse(base).warmup).toBeUndefined();
for (const warmup of [0, 80, 1_000]) {
const config = VideoConfigSchema.parse({ ...base, warmup });
expect(Number(config.warmup)).toBe(warmup);
expect(VideoConfigSchema.parse(JSON.parse(JSON.stringify(config)))).toEqual(config);
}
for (const warmup of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) {
expect(() => VideoConfigSchema.parse({ ...base, warmup })).toThrow();
}
});
4 changes: 4 additions & 0 deletions js/hang/src/catalog/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export const VideoConfigSchema = z.object({
),
),

// After a non-continuous join, decode from the group start, present at start plus warmup,
// and join that many milliseconds earlier.
warmup: z.optional(u53Schema),

// How far this rendition's frames reach the transport behind the broadcast's earliest
// rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never
// subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only
Expand Down
1 change: 0 additions & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ ladders.
- [Rust non-continuous signal](/quest/m1/rust-continuous.md) - the Rust container consumer reports a frame after a subscribe or discontinuity as non-continuous, like JS, for the tune-in and warmup trims
- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - a viewer joining at a recovery point drops the leading pictures it cannot decode; continuous viewers keep them
- [Watch decode errors](/quest/m1/watch-decode-error.md) - a WebCodecs error ends the subscription and the element reports it
- [Catalog warmup](/quest/m1/catalog-warmup.md) - `warmup` on video and audio renditions, in the catalog and the draft
- [Audio warmup](/quest/m1/audio-warmup.md) - a viewer joining an Opus rendition mid-stream never hears the unconverged first 80 ms
- [#3021](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - moq-gst picks the broadcast wall epoch; a restarted source is a new epoch, not a forward re-anchor
- [TS passthrough export](/quest/m1/ts-passthrough-export.md) - `export ts --passthrough` writes the `m2ts` track back byte-identical (less late drops) on a fixed delay
Expand Down
1 change: 0 additions & 1 deletion quest/m1/audio-warmup.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ frames decode independently and set nothing; HE-AAC is out of scope.

## Required

- [Catalog warmup](/quest/m1/catalog-warmup.md) - the field this reads and writes
- [Rust non-continuous signal](/quest/m1/rust-continuous.md) - the signal the trim keys on

## Related
Expand Down
32 changes: 0 additions & 32 deletions quest/m1/catalog-warmup.md

This file was deleted.

1 change: 0 additions & 1 deletion quest/m2/intra-refresh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,3 @@ Decisions the quests share:

- [Audio warmup](/quest/m1/audio-warmup.md) - Opus convergence after a mid-stream join uses the same `warmup` field
- [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - frames stamped before the group's keyframe are the other tune-in trim
- [Catalog warmup](/quest/m1/catalog-warmup.md) - the generic `warmup` field this line reads, kept in m1 for audio and open-GOP tune-in
1 change: 0 additions & 1 deletion quest/m2/intra-refresh/consumer-warmup.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ replaces both: the rule is timestamp arithmetic on the group start.

## Required

- [Catalog warmup](/quest/m1/catalog-warmup.md) - the field this reads
- [Rust non-continuous signal](/quest/m1/rust-continuous.md) - the Rust signal this keys on

## Related
Expand Down
1 change: 0 additions & 1 deletion quest/m2/intra-refresh/export-sync-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,4 @@ Audio `warmup` is Opus pre-roll and changes no export flag.

## Required

- [Catalog warmup](/quest/m1/catalog-warmup.md) - the field exports read
- [Consumer warmup](/quest/m2/intra-refresh/consumer-warmup.md) - the IDR check this reuses
4 changes: 0 additions & 4 deletions quest/m2/intra-refresh/h264-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,3 @@ were immediately decodable.
length-prefixed path; and a real clip generated with x264
`intra-refresh=1` added as a second round-trip in `test/ts/run.sh` next to
the closed-GOP one, asserting the catalog `warmup` and the group count.

## Required

- [Catalog warmup](/quest/m1/catalog-warmup.md) - the field import writes
32 changes: 32 additions & 0 deletions rs/hang/src/catalog/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ pub struct AudioConfig {
#[serde(default)]
pub jitter: Option<std::time::Duration>,

/// After a non-continuous join, decode from the group start, present at start plus warmup, and join that much earlier.
/// Serialized as whole milliseconds, rounded up so presentation never starts early.
#[serde_as(as = "Option<MillisCeil>")]
#[serde(default)]
pub warmup: Option<std::time::Duration>,

/// How far this rendition's frames reach the transport behind the broadcast's earliest
/// rendition, measured at the publisher from each rendition's minimum flush lateness.
/// Absent on the earliest rendition and on any rendition the publisher did not measure.
Expand Down Expand Up @@ -183,6 +189,7 @@ impl AudioConfig {
description: None,
container: Container::default(),
jitter: None,
warmup: None,
delay: None,
}
}
Expand All @@ -192,6 +199,31 @@ impl AudioConfig {
mod test {
use super::*;

#[test]
fn warmup_round_trips() {
let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
assert!(serde_json::to_value(&config).unwrap().get("warmup").is_none());
for millis in [0, 80, 1_000] {
config.warmup = Some(std::time::Duration::from_millis(millis));
let encoded = serde_json::to_value(&config).unwrap();
assert_eq!(encoded["warmup"], millis);
let decoded: AudioConfig = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.warmup, config.warmup);
}
}

#[test]
fn warmup_rounds_up() {
let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
for (nanos, millis) in [(1, 1), (499_999, 1), (333_200_000, 334)] {
config.warmup = Some(std::time::Duration::from_nanos(nanos));
let encoded = serde_json::to_value(&config).unwrap();
assert_eq!(encoded["warmup"], millis);
let decoded: AudioConfig = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.warmup, Some(std::time::Duration::from_millis(millis)));
}
}

#[test]
fn ranked_orders_by_enabled_then_bitrate_then_rate_then_channels() {
fn rendition(sample_rate: u32, channels: u32, bitrate: Option<u64>) -> AudioConfig {
Expand Down
27 changes: 19 additions & 8 deletions rs/hang/src/catalog/millis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,32 @@ use serde::ser::Error as _;
use serde::{Deserialize, Serialize, Serializer};
use serde_with::{DeserializeAs, SerializeAs};

/// A [`serde_with`] adapter encoding an optional [`Duration`] as whole milliseconds, rounded up.
/// A [`serde_with`] adapter encoding a [`Duration`] as whole milliseconds, rounded up.
///
/// Every duration on the wire here is an upper bound a consumer sizes a buffer against, so
/// rounding down is the one direction that breaks it. A 44.1 kHz AAC frame is 23.2 ms, and
/// truncating advertises 23; anything under a millisecond truncates to 0, which reads as
/// "flushed immediately" rather than "a little".
///
/// So `0` is not a value the field carries: a track that flushes each frame immediately omits it.
/// Reading one is how a publisher that truncated says "immediately", so it decodes as absent;
/// writing one is a caller stating something the field can't mean, so it's refused.
/// The optional implementation is for jitter and delay, where zero decodes as absent and
/// cannot be written. Fields such as warmup, where zero is meaningful, use `Option<MillisCeil>`.
pub(crate) struct MillisCeil;

impl SerializeAs<Duration> for MillisCeil {
fn serialize_as<S: Serializer>(source: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
let millis = source.as_nanos().div_ceil(1_000_000);
u64::try_from(millis)
.map_err(|_| S::Error::custom("a duration too long to express in milliseconds"))?
.serialize(serializer)
}
}

impl<'de> DeserializeAs<'de, Duration> for MillisCeil {
fn deserialize_as<D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
u64::deserialize(deserializer).map(Duration::from_millis)
}
}

impl SerializeAs<Option<Duration>> for MillisCeil {
fn serialize_as<S: Serializer>(source: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error> {
let Some(duration) = source else {
Expand All @@ -31,10 +45,7 @@ impl SerializeAs<Option<Duration>> for MillisCeil {
));
}

let millis = duration.as_nanos().div_ceil(1_000_000);
u64::try_from(millis)
.map_err(|_| S::Error::custom("a duration too long to express in milliseconds"))?
.serialize(serializer)
Self::serialize_as(duration, serializer)
}
}

Expand Down
2 changes: 2 additions & 0 deletions rs/hang/src/catalog/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ mod test {
framerate: None,
optimize_for_latency: None,
container: Container::Legacy,
warmup: None,
jitter: Some(std::time::Duration::from_millis(100)),
delay: Some(std::time::Duration::from_millis(200)),
},
Expand All @@ -381,6 +382,7 @@ mod test {
enabled: true,
description: None,
container: Container::Legacy,
warmup: None,
jitter: Some(std::time::Duration::from_millis(40)),
delay: None,
},
Expand Down
32 changes: 32 additions & 0 deletions rs/hang/src/catalog/video/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ pub struct VideoConfig {
#[serde(default)]
pub jitter: Option<std::time::Duration>,

/// After a non-continuous join, decode from the group start, present at start plus warmup, and join that much earlier.
/// Serialized as whole milliseconds, rounded up so presentation never starts early.
#[serde_as(as = "Option<MillisCeil>")]
#[serde(default)]
pub warmup: Option<std::time::Duration>,

/// How far this rendition's frames reach the transport behind the broadcast's earliest
/// rendition, measured at the publisher from each rendition's minimum flush lateness.
/// Absent on the earliest rendition and on any rendition the publisher did not measure.
Expand Down Expand Up @@ -294,6 +300,7 @@ impl VideoConfig {
optimize_for_latency: None,
container: Container::default(),
jitter: None,
warmup: None,
delay: None,
}
}
Expand All @@ -305,6 +312,31 @@ mod test {

use super::*;

#[test]
fn warmup_round_trips() {
let mut config = VideoConfig::new(VideoCodec::VP8);
assert!(serde_json::to_value(&config).unwrap().get("warmup").is_none());
for millis in [0, 80, 1_000] {
config.warmup = Some(std::time::Duration::from_millis(millis));
let encoded = serde_json::to_value(&config).unwrap();
assert_eq!(encoded["warmup"], millis);
let decoded: VideoConfig = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.warmup, config.warmup);
}
}

#[test]
fn warmup_rounds_up() {
let mut config = VideoConfig::new(VideoCodec::VP8);
for (nanos, millis) in [(1, 1), (499_999, 1), (333_200_000, 334)] {
config.warmup = Some(std::time::Duration::from_nanos(nanos));
let encoded = serde_json::to_value(&config).unwrap();
assert_eq!(encoded["warmup"], millis);
let decoded: VideoConfig = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.warmup, Some(std::time::Duration::from_millis(millis)));
}
}

#[test]
fn ranked_orders_by_picture_then_bitrate() {
fn rendition(size: Option<(u32, u32)>, bitrate: Option<u64>) -> VideoConfig {
Expand Down
Loading