Skip to content

Commit 87fe2ff

Browse files
kixelatedclaude
andauthored
feat(moq-mux,moq-srt): PTS-exposing TS Export API + PCR-paced SRT egress (#1845)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cfa1d26 commit 87fe2ff

6 files changed

Lines changed: 77 additions & 48 deletions

File tree

rs/moq-cli/src/subscribe.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,8 @@ impl Subscribe {
326326
.await?
327327
.with_latency(self.args.max_latency);
328328

329-
while let Some(chunk) = ts.next().await? {
330-
stdout.write_all(&chunk).await?;
329+
while let Some(frame) = ts.next().await? {
330+
stdout.write_all(&frame.payload).await?;
331331
stdout.flush().await?;
332332
}
333333

rs/moq-mux/src/container/ts/export.rs

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
//! MPEG-TS muxer.
22
//!
3-
//! [`Export`] subscribes to a MoQ broadcast and produces a single MPEG-TS byte
4-
//! stream: PAT/PMT program tables followed by one PES packet per media frame,
5-
//! packetized into 188-byte TS packets. Video is carried as Annex-B, audio as
6-
//! ADTS AAC.
3+
//! [`Export`] subscribes to a MoQ broadcast and produces MPEG-TS, yielding one
4+
//! [`Frame`] per media frame: PAT/PMT program tables followed by one PES packet,
5+
//! packetized into 188-byte TS packets. Each frame keeps its media timestamp so
6+
//! the caller can pace delivery on the media clock. Video is carried as Annex-B,
7+
//! audio as ADTS AAC.
78
//!
89
//! Video flows through [`ExportSource`], which normalizes every H.264/H.265
910
//! source to length-prefixed NALU plus a resolved avcC/hvcC (parsing in-band
@@ -47,9 +48,11 @@ const PSI_INTERVAL: Duration = Duration::from_millis(500);
4748

4849
/// Subscribe to a broadcast and produce an MPEG-TS byte stream.
4950
///
50-
/// Use [`next`](Self::next) to pull byte chunks: the first chunk is PAT+PMT, then
51-
/// each subsequent chunk is the TS packets for one media frame (preceded by a
52-
/// fresh PAT+PMT at video keyframes). Returns `None` when the broadcast ends.
51+
/// Use [`next`](Self::next) to pull one [`Frame`] per media frame: its `payload`
52+
/// is the TS packets, stamped with the source `timestamp` and `keyframe` flag.
53+
/// The leading PAT/PMT rides on the first frame (so it inherits a real
54+
/// timestamp), and is re-emitted at video keyframes and periodically for
55+
/// mid-stream tune-in. Returns `None` when the broadcast ends.
5356
pub struct Export<E: scte35::Catalog = ()> {
5457
broadcast: moq_net::BroadcastConsumer,
5558
catalog: Option<crate::catalog::Consumer<E>>,
@@ -159,12 +162,19 @@ impl<E: scte35::Catalog> Export<E> {
159162
self
160163
}
161164

162-
/// Get the next byte chunk.
163-
pub async fn next(&mut self) -> anyhow::Result<Option<Bytes>> {
165+
/// Get the next muxed frame.
166+
///
167+
/// Each [`Frame`] carries the TS packets for one media frame in `payload`,
168+
/// stamped with that frame's media `timestamp` and `keyframe` flag so a
169+
/// transport can pace delivery on the media clock. The leading PAT/PMT rides
170+
/// on the first frame (inheriting its timestamp), and is re-emitted at video
171+
/// keyframes and periodically for mid-stream tune-in. Returns `None` when the
172+
/// broadcast ends. `duration` is always `None`: the muxer has no use for it.
173+
pub async fn next(&mut self) -> anyhow::Result<Option<Frame>> {
164174
kio::wait(|waiter| self.poll_next(waiter)).await
165175
}
166176

167-
pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<anyhow::Result<Option<Bytes>>> {
177+
pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll<anyhow::Result<Option<Frame>>> {
168178
// 1. Drain catalog updates, discovering the track layout.
169179
while let Some(catalog) = self.catalog.as_mut() {
170180
match catalog.poll_next(waiter)? {
@@ -207,8 +217,10 @@ impl<E: scte35::Catalog> Export<E> {
207217
}
208218
}
209219

210-
// 3. Emit the program tables once the layout is resolved and every
211-
// track's codec config is ready.
220+
// 3. Build the program tables once the layout is resolved and every
221+
// track's codec config is ready. The tables aren't emitted here: PSI has
222+
// no media time of its own, so `write_frame` prepends them to the first
223+
// frame instead, letting the leading PAT/PMT inherit a real timestamp.
212224
if self.psi.is_none() {
213225
if self.tracks.is_empty() {
214226
// No tracks yet. If the catalog is also done, the broadcast is empty.
@@ -226,15 +238,14 @@ impl<E: scte35::Catalog> Export<E> {
226238
return Poll::Pending;
227239
}
228240
self.build_psi()?;
229-
let header = self.write_psi()?;
230-
return Poll::Ready(Ok(Some(header)));
231241
}
232242

233-
// 4. Emit the smallest-timestamp pending frame as a PES packet.
243+
// 4. Emit the smallest-timestamp pending frame as a PES packet (the first
244+
// one carries the buffered PAT/PMT).
234245
if let Some(name) = self.pick_next_track() {
235246
let frame = self.tracks.get_mut(&name).unwrap().pending.take().unwrap();
236-
let chunk = self.write_frame(&name, frame)?;
237-
return Poll::Ready(Ok(Some(chunk)));
247+
let out = self.write_frame(&name, frame)?;
248+
return Poll::Ready(Ok(Some(out)));
238249
}
239250

240251
// 5. End of stream once every track has drained and the catalog is closed.
@@ -444,18 +455,7 @@ impl<E: scte35::Catalog> Export<E> {
444455
Ok(())
445456
}
446457

447-
/// Serialize a fresh PAT + PMT into a chunk.
448-
fn write_psi(&mut self) -> anyhow::Result<Bytes> {
449-
let psi = self.psi.as_ref().context("PSI not built")?;
450-
let pat = TsPayload::Pat(psi.pat.clone());
451-
let pmt = TsPayload::Pmt(psi.pmt.clone());
452-
453-
let mut out = Vec::with_capacity(2 * TsPacket::SIZE);
454-
self.write_packet(&mut out, Pid::PAT, None, pat)?;
455-
self.write_packet(&mut out, PMT_PID, None, pmt)?;
456-
Ok(Bytes::from(out))
457-
}
458-
458+
/// Name of the track whose pending frame has the smallest timestamp.
459459
fn pick_next_track(&self) -> Option<String> {
460460
self.tracks
461461
.iter()
@@ -464,14 +464,18 @@ impl<E: scte35::Catalog> Export<E> {
464464
.map(|(n, _)| n)
465465
}
466466

467-
/// Packetize one media frame into a chunk, re-emitting PAT/PMT before video
468-
/// keyframes (and periodically) so receivers can tune in mid-stream.
469-
fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result<Bytes> {
467+
/// Packetize one media frame into an output [`Frame`], re-emitting PAT/PMT
468+
/// before video keyframes (and periodically) so receivers can tune in
469+
/// mid-stream. The returned frame keeps the source `timestamp` and `keyframe`
470+
/// flag so the caller can pace it.
471+
fn write_frame(&mut self, name: &str, frame: Frame) -> anyhow::Result<Frame> {
470472
let track = self.tracks.get(name).context("missing track")?;
471473
let pid = track.pid;
472474
let kind = track.kind.clone();
473475
let is_pcr = self.psi.as_ref().is_some_and(|p| p.pcr_pid == pid);
474476
let is_video = matches!(kind, Kind::Video(_));
477+
let timestamp = frame.timestamp;
478+
let keyframe = frame.keyframe;
475479

476480
// Build the elementary-stream payload for this frame. Video needs the
477481
// resolved avcC/hvcC to rewrite length-prefixed NALs as Annex-B. SCTE-35
@@ -525,7 +529,12 @@ impl<E: scte35::Catalog> Export<E> {
525529
self.write_pes(&mut out, &unit, &es_payload)?;
526530
}
527531
}
528-
Ok(Bytes::from(out))
532+
Ok(Frame {
533+
timestamp,
534+
duration: None,
535+
payload: Bytes::from(out),
536+
keyframe,
537+
})
529538
}
530539

531540
/// Packetize a PES payload into 188-byte TS packets.

rs/moq-mux/src/container/ts/export_test.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ async fn drain_with<E: scte35::Catalog>(mut exporter: Export<E>) -> BytesMut {
6666
let mut out = BytesMut::new();
6767
// `while let Ok` stops on the first timeout (`Pending`: no more output).
6868
while let Ok(res) = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await {
69-
let Some(chunk) = res.expect("exporter error") else {
69+
let Some(frame) = res.expect("exporter error") else {
7070
break;
7171
};
72-
out.extend_from_slice(&chunk);
72+
out.extend_from_slice(&frame.payload);
7373
}
7474
out
7575
}

rs/moq-mux/src/container/ts/import_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ async fn import_export_import_roundtrip() {
212212
let mut out = BytesMut::new();
213213
while let Ok(res) = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await {
214214
match res.expect("exporter error") {
215-
Some(chunk) => out.extend_from_slice(&chunk),
215+
Some(frame) => out.extend_from_slice(&frame.payload),
216216
None => break,
217217
}
218218
}

rs/moq-srt/src/listen.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,18 +238,36 @@ async fn serve_request(origin: OriginConsumer, path: &str, mut socket: srt_tokio
238238
};
239239

240240
// MPEG-TS is a continuous byte stream, so we coalesce the muxer's per-frame
241-
// chunks and slice them on a fixed boundary rather than preserving them.
241+
// output and slice it on a fixed boundary rather than preserving frames.
242+
//
243+
// Pace on the media clock: stamp each SRT payload with the media time of the
244+
// frame it carries, anchored to when the first frame went out. SRT's TSBPD
245+
// reconstructs that inter-frame spacing at the receiver, so a tune-in keyframe
246+
// burst is released at the media rate instead of all at once. (The Instant
247+
// passed to `send` is the packet's origin time feeding TSBPD, not a "send now"
248+
// instruction; `Instant::now()` would collapse the spacing into a burst.)
249+
//
250+
// We pace on the frame's presentation timestamp (the only clock the muxer
251+
// exposes) while frames transmit in decode order, so a B-frame stream's
252+
// per-GOP reorder leaves `send_at` slightly non-monotonic. That's harmless:
253+
// `saturating_sub` keeps it >= the anchor, and the receiver reorders from the
254+
// PTS/DTS carried inside the TS payload regardless.
255+
let anchor = Instant::now();
256+
let mut base = None;
257+
let mut send_at = anchor;
242258
let mut buffer = bytes::BytesMut::new();
243-
while let Some(chunk) = subscriber.next().await? {
244-
buffer.extend_from_slice(&chunk);
259+
while let Some(frame) = subscriber.next().await? {
260+
let origin = *base.get_or_insert(frame.timestamp);
261+
send_at = anchor + Duration::from(frame.timestamp).saturating_sub(Duration::from(origin));
262+
263+
buffer.extend_from_slice(&frame.payload);
245264
while buffer.len() >= SRT_PAYLOAD {
246-
let payload = buffer.split_to(SRT_PAYLOAD).freeze();
247-
socket.send((Instant::now(), payload)).await?;
265+
socket.send((send_at, buffer.split_to(SRT_PAYLOAD).freeze())).await?;
248266
}
249267
}
250268

251269
if !buffer.is_empty() {
252-
socket.send((Instant::now(), buffer.freeze())).await?;
270+
socket.send((send_at, buffer.freeze())).await?;
253271
}
254272
socket.close().await?;
255273

rs/moq-srt/src/ts.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! it back to MPEG-TS for an SRT caller (VLC, ffmpeg) to play.
99
1010
use bytes::Bytes;
11-
use moq_mux::container::ts;
11+
use moq_mux::container::{Frame, ts};
1212
use moq_net::{BroadcastInfo, OriginConsumer, OriginProducer, OriginPublish};
1313

1414
use crate::Result;
@@ -62,7 +62,8 @@ impl Publisher {
6262
///
6363
/// The mirror of [`Publisher`]: where that demuxes SRT-carried TS into the
6464
/// origin, this consumes a broadcast from the origin and re-muxes it to TS so an
65-
/// SRT caller can play it. Pull byte chunks with [`next`](Self::next).
65+
/// SRT caller can play it. Pull frames with [`next`](Self::next); each carries
66+
/// the TS bytes plus the media timestamp used to pace delivery.
6667
pub struct Subscriber {
6768
export: ts::Export,
6869
}
@@ -82,8 +83,9 @@ impl Subscriber {
8283
Ok(Some(Self { export }))
8384
}
8485

85-
/// Pull the next MPEG-TS byte chunk, or `None` once the broadcast ends.
86-
pub async fn next(&mut self) -> Result<Option<Bytes>> {
86+
/// Pull the next muxed frame (TS bytes + media timestamp), or `None` once the
87+
/// broadcast ends.
88+
pub async fn next(&mut self) -> Result<Option<Frame>> {
8789
Ok(self.export.next().await?)
8890
}
8991
}

0 commit comments

Comments
 (0)