Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f6754b2
moq-net: replace const-generic Timescale<SCALE> with runtime Timestamp
kixelated May 23, 2026
1e5a398
hang catalog jitter: Option<Duration> serialized as integer ms
kixelated May 23, 2026
00bfa30
hang catalog: use serde_with::DurationMilliSeconds for jitter
kixelated May 23, 2026
5ac1cd9
moq-net: tighten Timestamp arithmetic and cross-scale ordering
kixelated May 23, 2026
8673fa9
Merge remote-tracking branch 'origin/main' into claude/gifted-bardeen…
kixelated May 23, 2026
716f0e9
moq-msf: jitter Option<Duration>, matches hang catalog
kixelated May 23, 2026
71078c0
moq-msf: drop fractional jitter precision, match hang exactly
kixelated May 23, 2026
c993966
moq-net: Timestamp::cmp tie-breaks to satisfy Ord/Eq contract
kixelated May 23, 2026
8a2f3eb
Drop container Timestamp re-export, dedup moq-loc varint
kixelated May 23, 2026
6aed9f0
moq-net: From<Timestamp> for Duration; un-export coding module
kixelated May 23, 2026
6aece1b
moq-net + moq-mux: scale/B-frame robustness + doc/thiserror nits
kixelated May 23, 2026
9761ee9
Merge remote-tracking branch 'origin/main' into claude/gifted-bardeen…
kixelated May 24, 2026
1e77fef
Merge remote-tracking branch 'origin/main' into claude/gifted-bardeen…
kixelated May 27, 2026
3ae46d0
Merge remote-tracking branch 'origin/main' into claude/gifted-bardeen…
kixelated May 27, 2026
3cebbf9
Merge remote-tracking branch 'origin/dev' into claude/gifted-bardeen-…
kixelated May 28, 2026
2d0f818
moq-net: Timescale is NonZero<u64>; drop UNKNOWN sentinel
kixelated May 28, 2026
8dfcd86
moq-net: Timescale::new -> Result; drop Timestamp::from_scale
kixelated May 28, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions rs/hang/examples/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> {

// Not real frames of course. The first frame is a keyframe and starts the first group.
let frame = moq_mux::container::Frame {
timestamp: moq_mux::container::Timestamp::from_secs(1).unwrap(),
timestamp: moq_net::Timestamp::from_secs(1).unwrap(),
payload: Bytes::from_static(b"keyframe NAL data"),
keyframe: true,
};
Expand All @@ -112,7 +112,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;

let frame = moq_mux::container::Frame {
timestamp: moq_mux::container::Timestamp::from_secs(2).unwrap(),
timestamp: moq_net::Timestamp::from_secs(2).unwrap(),
payload: Bytes::from_static(b"delta NAL data"),
keyframe: false,
};
Expand All @@ -122,7 +122,7 @@ async fn run_broadcast(origin: moq_net::OriginProducer) -> anyhow::Result<()> {

// Marking this frame as a keyframe closes the current group and starts a new one.
let frame = moq_mux::container::Frame {
timestamp: moq_mux::container::Timestamp::from_secs(3).unwrap(),
timestamp: moq_net::Timestamp::from_secs(3).unwrap(),
payload: Bytes::from_static(b"keyframe NAL data"),
keyframe: true,
};
Expand Down
7 changes: 5 additions & 2 deletions rs/hang/src/catalog/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::collections::{BTreeMap, btree_map};
use bytes::Bytes;

use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, hex::Hex};
use serde_with::{DisplayFromStr, DurationMilliSeconds, hex::Hex};

use crate::catalog::Container;

Expand Down Expand Up @@ -92,10 +92,13 @@ pub struct AudioConfig {
/// The player's jitter buffer should be larger than this value.
/// If not provided, the player should assume each frame is flushed immediately.
///
/// Serialized as an integer number of milliseconds (sub-ms precision is truncated).
///
/// NOTE: The audio "frame" duration depends on the codec, sample rate, etc.
/// ex: AAC often uses 1024 samples per frame, so at 44100Hz, this would be 1024/44100 = 23ms
#[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
#[serde(default)]
pub jitter: Option<moq_net::Time>,
pub jitter: Option<std::time::Duration>,
}

impl AudioConfig {
Expand Down
88 changes: 88 additions & 0 deletions rs/hang/src/catalog/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,92 @@ mod test {
let output = decoded.to_string().expect("failed to encode");
assert_eq!(encoded, output, "wrong encoded output");
}

/// Lock in the on-wire shape of the jitter field: a bare integer number
/// of milliseconds. If `Option<Duration>` ever loses the `duration_millis`
/// serde adapter, this regresses to serde's default `{secs, nanos}` shape.
#[test]
fn jitter_serialized_as_millis() {
let mut encoded = r#"{
"video": {
"renditions": {
"video": {
"codec": "avc1.64001f",
"container": {"kind": "legacy"},
"jitter": 100
}
}
},
"audio": {
"renditions": {
"audio": {
"codec": "opus",
"sampleRate": 48000,
"numberOfChannels": 2,
"container": {"kind": "legacy"},
"jitter": 40
}
}
}
}"#
.to_string();
encoded.retain(|c| !c.is_whitespace());

let mut video_renditions = BTreeMap::new();
video_renditions.insert(
"video".to_string(),
VideoConfig {
codec: H264 {
profile: 0x64,
constraints: 0x00,
level: 0x1f,
inline: false,
}
.into(),
description: None,
coded_width: None,
coded_height: None,
display_ratio_width: None,
display_ratio_height: None,
bitrate: None,
framerate: None,
optimize_for_latency: None,
container: Container::Legacy,
jitter: Some(std::time::Duration::from_millis(100)),
},
);

let mut audio_renditions = BTreeMap::new();
audio_renditions.insert(
"audio".to_string(),
AudioConfig {
codec: Opus,
sample_rate: 48_000,
channel_count: 2,
bitrate: None,
description: None,
container: Container::Legacy,
jitter: Some(std::time::Duration::from_millis(40)),
},
);

let catalog = Catalog {
video: Video {
renditions: video_renditions,
display: None,
rotation: None,
flip: None,
},
audio: Audio {
renditions: audio_renditions,
},
..Default::default()
};

let decoded = Catalog::from_str(&encoded).expect("failed to decode");
assert_eq!(catalog, decoded, "decode mismatch");

let output = catalog.to_string().expect("failed to encode");
assert_eq!(encoded, output, "encode mismatch");
}
}
7 changes: 5 additions & 2 deletions rs/hang/src/catalog/video/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use std::collections::{BTreeMap, btree_map};

use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, hex::Hex};
use serde_with::{DisplayFromStr, DurationMilliSeconds, hex::Hex};

use crate::catalog::Container;

Expand Down Expand Up @@ -141,12 +141,15 @@ pub struct VideoConfig {
/// The player's jitter buffer should be larger than this value.
/// If not provided, the player should assume each frame is flushed immediately.
///
/// Serialized as an integer number of milliseconds (sub-ms precision is truncated).
///
/// ex:
/// - If each frame is flushed immediately, this would be 1000/fps.
/// - If there can be up to 3 b-frames in a row, this would be 3 * 1000/fps.
/// - If frames are buffered into 2s segments, this would be 2s.
#[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
#[serde(default)]
pub jitter: Option<moq_net::Time>,
pub jitter: Option<std::time::Duration>,
}

impl VideoConfig {
Expand Down
24 changes: 21 additions & 3 deletions rs/hang/src/container/frame.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
use bytes::{Buf, Bytes, BytesMut};
use derive_more::Debug;
use moq_net::VarInt;

use crate::Error;

pub type Timestamp = moq_net::Timescale<1_000_000>;
pub use moq_net::{Timescale, Timestamp};

/// Canonical timescale for the hang legacy wire format: microseconds.
///
/// The legacy container's on-wire timestamp is a single VarInt with no scale tag,
/// so encoders normalize to this scale and decoders attach it.
pub const TIMESCALE: Timescale = Timescale::MICRO;

/// A media frame with a timestamp and codec-specific payload.
///
Expand All @@ -30,9 +37,16 @@ pub struct Frame {
impl Frame {
/// Encode the frame to the given group as a single moq-lite frame:
/// VarInt timestamp prefix followed by the raw codec payload.
///
/// The timestamp is normalized to [`TIMESCALE`] (microseconds) on the wire so
/// peers using a different source scale (e.g. nanoseconds from MKV) can decode
/// without knowing the producer's internal scale.
pub fn encode(&self, group: &mut moq_net::GroupProducer) -> Result<(), Error> {
let timestamp = self.timestamp.convert(TIMESCALE)?;
let value = VarInt::try_from(timestamp.value()).map_err(moq_net::Error::from)?;

let mut header = BytesMut::new();
self.timestamp.encode(&mut header).map_err(moq_net::Error::from)?;
value.encode_quic(&mut header).map_err(moq_net::Error::from)?;

let size = header.len() + self.payload.len();

Expand All @@ -45,8 +59,12 @@ impl Frame {
}

/// Decode a frame from raw bytes (VarInt timestamp prefix + payload).
///
/// Attaches [`TIMESCALE`] (microseconds) to the decoded timestamp, matching what
/// [`Self::encode`] writes.
pub fn decode(mut buf: impl Buf) -> Result<Self, Error> {
let timestamp = Timestamp::decode(&mut buf)?;
let value: u64 = VarInt::decode_quic(&mut buf).map_err(moq_net::Error::from)?.into();
let timestamp = Timestamp::new(value, TIMESCALE)?;
let payload = buf.copy_to_bytes(buf.remaining());

Ok(Self { timestamp, payload })
Expand Down
3 changes: 2 additions & 1 deletion rs/moq-audio/src/producer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

use bytes::Bytes;

use moq_mux::container::{Frame as MuxFrame, Timestamp};
use moq_mux::container::Frame as MuxFrame;
use moq_net::Timestamp;

use crate::codec::{Encoder, EncoderInput, EncoderOutput};
use crate::resample::Resampler;
Expand Down
11 changes: 7 additions & 4 deletions rs/moq-gst/src/source/imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,10 +525,13 @@ async fn run_track_pump(
let buffer_mut = buffer.get_mut().unwrap();

let pts = match reference_ts {
Some(reference) => {
let delta: Duration = (timestamp - reference).into();
gst::ClockTime::from_nseconds(delta.as_nanos() as u64)
}
Some(reference) => match timestamp.checked_sub(reference) {
Ok(delta) => {
let d: Duration = delta.into();
gst::ClockTime::from_nseconds(d.as_nanos() as u64)
}
Err(_) => gst::ClockTime::ZERO,
},
None => {
reference_ts = Some(timestamp);
gst::ClockTime::ZERO
Expand Down
1 change: 1 addition & 0 deletions rs/moq-loc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ doctest = false

[dependencies]
bytes = "1"
moq-net = { workspace = true }
thiserror = "2"
Loading
Loading