use std::io::Cursor;
use std::time::Duration;
use bytes::{Bytes, BytesMut};
use hang::catalog::{AAC, AudioCodec, AudioConfig, Container, H264, VideoConfig};
use mpeg2ts::es::StreamType;
use mpeg2ts::pes::{PesPacketReader, ReadPesPacket};
use mpeg2ts::ts::{ReadTsPacket, TsPacketReader, TsPayload};
use crate::catalog::hang::Container as HangContainer;
use crate::container::ts::export::PCR_INTERVAL;
use crate::container::ts::{Export, Stats, catalog as tscat};
use crate::container::{Frame, Kind, Producer};
use moq_net::Timestamp;
const SC: &[u8] = &[0, 0, 0, 1];
const SPS: &[u8] = &[0x67, 0x42, 0xc0, 0x1f, 0xde];
const PPS: &[u8] = &[0x68, 0xce, 0x3c, 0x80];
const PPS1: &[u8] = &[0x68, 0xce, 0x3c, 0x81];
const CUE: &[u8] = &[
0xfc, 0x30, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xf0, 0x0a, 0x05, 0x00, 0x00, 0x2b, 0xb4, 0x7f,
0xdf, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xad, 0x25, 0xe8, 0x39,
];
fn annexb(nals: &[&[u8]]) -> Bytes {
let mut buf = BytesMut::new();
for nal in nals {
buf.extend_from_slice(SC);
buf.extend_from_slice(nal);
}
buf.freeze()
}
fn length_prefixed(nals: &[&[u8]]) -> Bytes {
let mut buf = BytesMut::new();
for nal in nals {
buf.extend_from_slice(&(nal.len() as u32).to_be_bytes());
buf.extend_from_slice(nal);
}
buf.freeze()
}
const RECORDING_MAX_AGE: Duration = Duration::from_secs(30);
async fn drain(consumer: moq_net::broadcast::Consumer) -> BytesMut {
drain_with(Export::new(crate::source::announced(&consumer)).await.unwrap()).await
}
async fn drain_with<E: tscat::Catalog>(exporter: Export<E>) -> BytesMut {
let mut exporter = exporter.with_max_age(RECORDING_MAX_AGE);
let mut out = BytesMut::new();
while let Ok(res) = tokio::time::timeout(Duration::from_secs(1), exporter.next()).await {
let Some(frame) = res.expect("exporter error") else {
break;
};
out.extend_from_slice(&frame.payload);
}
out
}
fn is_pcr_frame(frame: &Frame) -> bool {
frame.payload.len() == 188 && frame.payload[3] & 0x30 == 0x20
}
fn assert_packet_aligned(ts: &[u8]) {
assert!(!ts.is_empty(), "no TS output");
assert_eq!(ts.len() % 188, 0, "output not a whole number of 188-byte packets");
assert!(
ts.chunks(188).all(|p| p[0] == 0x47),
"every packet must start with the sync byte"
);
}
#[tokio::test(start_paused = true)]
async fn export_aac_roundtrip() {
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 track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog.modify().unwrap().audio.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let frames: Vec<Bytes> = vec![
Bytes::from_static(&[0x01, 0x02, 0x03, 0x04]),
Bytes::from_static(&[0x10, 0x11, 0x12, 0x13, 0x14]),
Bytes::from(vec![0x20u8; 200]),
];
for (i, payload) in frames.iter().enumerate() {
producer
.write(Frame {
timestamp: Timestamp::from_micros(i as u64 * 20_000).unwrap(),
duration: None,
payload: payload.clone(),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
}
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut saw_pat = false;
let mut saw_pmt = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
match packet.payload {
Some(TsPayload::Pat(_)) => saw_pat = true,
Some(TsPayload::Pmt(pmt)) => {
saw_pmt = true;
assert_eq!(pmt.es_info.len(), 1);
assert_eq!(pmt.es_info[0].stream_type, StreamType::AdtsAac);
}
_ => {}
}
}
assert!(saw_pat, "missing PAT");
assert!(saw_pmt, "missing PMT");
let mut pes = PesPacketReader::new(TsPacketReader::new(Cursor::new(ts.as_ref())));
let mut recovered: Vec<(u64, Vec<u8>)> = Vec::new();
while let Some(packet) = pes.read_pes_packet().unwrap() {
let pts = packet.header.pts.expect("PES carried no PTS").as_u64();
assert!(packet.data.len() >= 7, "PES payload shorter than an ADTS header");
recovered.push((pts, packet.data[7..].to_vec()));
}
assert_eq!(recovered.len(), frames.len());
for (i, payload) in frames.iter().enumerate() {
let (pts, raw) = &recovered[i];
assert_eq!(*pts, i as u64 * 20 * 90, "PTS should be ms * 90 (90 kHz)");
assert_eq!(raw.as_slice(), payload.as_ref(), "raw AAC payload mismatch");
}
}
fn collect_pes_pts(ts: &[u8]) -> (Vec<u64>, Vec<u64>) {
let mut reader = TsPacketReader::new(Cursor::new(ts));
let (mut video_pid, mut audio_pid) = (None, None);
let (mut video, mut audio) = (Vec::new(), Vec::new());
while let Some(packet) = reader.read_ts_packet().unwrap() {
match packet.payload {
Some(TsPayload::Pmt(pmt)) => {
for es in &pmt.es_info {
match es.stream_type {
StreamType::H264 => video_pid = Some(es.elementary_pid),
StreamType::AdtsAac => audio_pid = Some(es.elementary_pid),
_ => {}
}
}
}
Some(TsPayload::PesStart(pes)) => {
if let Some(pts) = pes.header.pts {
let pid = Some(packet.header.pid);
if pid == video_pid {
video.push(pts.as_u64());
} else if pid == audio_pid {
audio.push(pts.as_u64());
}
}
}
_ => {}
}
}
(video, audio)
}
async fn export_lead_audio() -> BytesMut {
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 vtrack = broadcast
.create_track(
broadcast.unique_name(".avc3"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
{
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: true,
});
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.video
.renditions
.insert(vtrack.name().to_string(), cfg);
}
let mut video = Producer::new(vtrack, HangContainer::Legacy(crate::container::Kind::Data));
let atrack = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
{
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.audio
.renditions
.insert(atrack.name().to_string(), cfg);
}
let mut audio = Producer::new(atrack, HangContainer::Legacy(crate::container::Kind::Data));
let audio_frame = |ms: u64| Frame {
timestamp: Timestamp::from_micros(ms * 1_000).unwrap(),
duration: None,
payload: Bytes::from(vec![0xAAu8; 16]),
keyframe: true,
};
for ms in [0, 20, 40, 60, 80] {
audio.write(audio_frame(ms)).unwrap();
audio.cut(None).unwrap();
}
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 200));
video
.write(Frame {
timestamp: Timestamp::from_micros(100_000).unwrap(),
duration: None,
payload: annexb(&[SPS, PPS, &idr]),
keyframe: true,
})
.unwrap();
video.cut(None).unwrap();
for ms in [100, 120, 140] {
audio.write(audio_frame(ms)).unwrap();
audio.cut(None).unwrap();
}
video.finish().unwrap();
audio.finish().unwrap();
let exporter = Export::new(crate::source::announced(&consumer)).await.unwrap();
drain_with(exporter).await
}
#[tokio::test(start_paused = true)]
async fn export_starts_at_video_keyframe() {
const KEYFRAME_PTS: u64 = 100 * 90;
let ts = export_lead_audio().await;
assert_packet_aligned(&ts);
let (video, audio) = collect_pes_pts(&ts);
assert_eq!(
video.first(),
Some(&KEYFRAME_PTS),
"the stream must begin at the video keyframe"
);
assert!(
audio.iter().all(|&p| p >= KEYFRAME_PTS),
"lead audio before the first keyframe must be dropped, got {audio:?}"
);
assert!(!audio.is_empty(), "audio from the keyframe onward is still carried");
}
fn reassemble_video(ts: &[u8], expected_stream_type: StreamType) -> Vec<u8> {
let mut reader = TsPacketReader::new(Cursor::new(ts));
let mut video_pid = None;
let mut saw_random_access = false;
let mut saw_pcr = false;
let mut reassembled: Vec<u8> = Vec::new();
let mut unbounded = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
match packet.payload {
Some(TsPayload::Pmt(pmt)) => {
assert_eq!(pmt.es_info.len(), 1);
assert_eq!(pmt.es_info[0].stream_type, expected_stream_type);
video_pid = Some(pmt.es_info[0].elementary_pid);
}
Some(TsPayload::PesStart(pes)) => {
if let Some(af) = &packet.adaptation_field {
saw_random_access |= af.random_access_indicator;
}
unbounded = pes.pes_packet_len == 0;
reassembled.extend_from_slice(&pes.data);
}
Some(TsPayload::PesContinuation(bytes)) => reassembled.extend_from_slice(&bytes),
None => saw_pcr |= packet.adaptation_field.as_ref().is_some_and(|af| af.pcr.is_some()),
_ => {}
}
}
assert!(video_pid.is_some(), "missing video PMT entry");
assert!(saw_random_access, "keyframe should set random_access_indicator");
assert!(saw_pcr, "PCR pid should carry the clock");
assert!(unbounded, "video PES should be unbounded");
reassembled
}
#[tokio::test(start_paused = true)]
async fn export_avc3_in_band_reassembles() {
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 track = broadcast
.create_track(
broadcast.unique_name(".avc3"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: true,
});
cfg.container = Container::Legacy;
catalog.modify().unwrap().video.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
producer
.write(Frame {
timestamp: Timestamp::from_micros(0).unwrap(),
duration: None,
payload: annexb(&[SPS, PPS, &idr]),
keyframe: true,
})
.unwrap();
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let reassembled = reassemble_video(&ts, StreamType::H264);
assert_eq!(reassembled.as_slice(), annexb(&[SPS, PPS, &idr]).as_ref());
}
#[tokio::test(start_paused = true)]
async fn export_avc3_preserves_multiple_pps() {
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 track = broadcast
.create_track(
broadcast.unique_name(".avc3"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: true,
});
cfg.container = Container::Legacy;
catalog.modify().unwrap().video.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
producer
.write(Frame {
timestamp: Timestamp::from_millis(0).unwrap(),
duration: None,
payload: annexb(&[SPS, PPS, PPS1, &idr]),
keyframe: true,
})
.unwrap();
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let reassembled = reassemble_video(&ts, StreamType::H264);
assert_eq!(reassembled.as_slice(), annexb(&[SPS, PPS, PPS1, &idr]).as_ref());
}
#[tokio::test(start_paused = true)]
async fn export_avc1_out_of_band_reassembles() {
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 avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
catalog.modify().unwrap().video.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
producer
.write(Frame {
timestamp: Timestamp::from_micros(0).unwrap(),
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let reassembled = reassemble_video(&ts, StreamType::H264);
assert_eq!(reassembled.as_slice(), annexb(&[SPS, PPS, &idr]).as_ref());
}
#[tokio::test(start_paused = true)]
async fn export_import_h265_keeps_suffix_sei_on_its_picture() {
use crate::codec::h265::fixtures::{PPS, SPS, VPS};
const IDR: &[u8] = &[0x26, 0x01, 0x80, 0xaa]; const TRAIL: &[u8] = &[0x02, 0x01, 0x80, 0x33]; const AUD: &[u8] = &[0x46, 0x01, 0x50]; const PREFIX_SEI: &[u8] = &[0x4e, 0x01, 0x01, 0x04, 0x80]; const SUFFIX_SEI: &[u8] = &[0x50, 0x01, 0x84, 0x02, 0x80]; const SUFFIX_SEI2: &[u8] = &[0x50, 0x01, 0x05, 0x03, 0x80];
let units: [Vec<&[u8]>; 3] = [
vec![VPS, SPS, PPS, IDR, SUFFIX_SEI, SUFFIX_SEI2],
vec![PREFIX_SEI, TRAIL, SUFFIX_SEI],
vec![AUD, TRAIL, SUFFIX_SEI],
];
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 track = broadcast
.create_track(
broadcast.unique_name(".hev1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = crate::codec::h265::config(&annexb(&units[0])).unwrap();
cfg.container = Container::Legacy;
catalog.modify().unwrap().video.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
for (i, nals) in units.iter().enumerate() {
producer
.write(Frame {
timestamp: Timestamp::from_millis(i as u64 * 40).unwrap(),
duration: None,
payload: annexb(nals),
keyframe: i == 0,
})
.unwrap();
}
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let all: Vec<&[u8]> = units.iter().flatten().copied().collect();
let reassembled = reassemble_video(&ts, StreamType::H265);
assert_eq!(reassembled.as_slice(), annexb(&all).as_ref());
let mut imported = moq_net::broadcast::Info::new().produce();
let imported_consumer = imported.consume();
let import_catalog = crate::catalog::Producer::new(&mut imported, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(imported, import_catalog.reserve());
import.decode(&ts).unwrap();
import.finish().unwrap();
let snapshot = import_catalog.snapshot();
let (imported_name, video) = snapshot.video.renditions.iter().next().expect("an H.265 rendition");
assert!(video.codec.to_string().starts_with("hev1"), "codec was {}", video.codec);
let recovered = read_frames(&imported_consumer, imported_name, Kind::Video).await;
assert_eq!(recovered.len(), units.len(), "access unit count");
for (i, (got, nals)) in recovered.iter().zip(&units).enumerate() {
assert_eq!(got.as_slice(), annexb(nals).as_ref(), "access unit {i}");
}
}
#[tokio::test(start_paused = true)]
async fn export_bframe_video_authors_dts() {
let data = include_bytes!("test_data/scte35/kyrion_dirtystart.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut video_pid = None;
let mut pts = Vec::new();
let mut authored = 0usize;
let mut effective = Vec::new();
while let Some(packet) = reader.read_ts_packet().unwrap() {
match packet.payload {
Some(TsPayload::Pmt(pmt)) if video_pid.is_none() => {
video_pid = pmt
.es_info
.iter()
.find(|e| e.stream_type == StreamType::H264)
.map(|e| e.elementary_pid);
}
Some(TsPayload::PesStart(pes)) if Some(packet.header.pid) == video_pid => {
let p = pes.header.pts.expect("video PES carried no PTS").as_u64();
let d = pes.header.dts.map(|t| t.as_u64());
if d.is_some() {
authored += 1;
}
effective.push(d.unwrap_or(p));
pts.push(p);
}
_ => {}
}
}
assert!(video_pid.is_some(), "missing H.264 video PMT entry");
assert!(pts.len() > 50, "expected the full feed, got {} frames", pts.len());
assert!(
pts.windows(2).any(|w| w[1] < w[0]),
"fixture must carry reordered B-frames"
);
assert!(authored > 0, "no DTS authored for a B-frame stream");
for (i, win) in effective.windows(2).enumerate() {
assert!(win[1] > win[0], "DTS not strictly increasing at frame {i}: {win:?}");
}
for (i, (&d, &p)) in effective.iter().zip(pts.iter()).enumerate() {
assert!(d <= p, "DTS {d} after PTS {p} at frame {i}");
}
}
#[tokio::test(start_paused = true)]
async fn export_pcr_is_a_uniform_ramp() {
let data = include_bytes!("test_data/scte35/kyrion_dirtystart.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut pcr_pid = None;
let mut pcr_pids = Vec::new();
let mut pcrs: Vec<u64> = Vec::new();
let mut units = 0usize;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(pcr) = packet.adaptation_field.as_ref().and_then(|af| af.pcr) {
pcr_pids.push(packet.header.pid);
assert!(packet.payload.is_none(), "PCR rides adaptation-field-only packets");
pcrs.push(pcr.as_u64() / 300);
}
match packet.payload {
Some(TsPayload::Pmt(pmt)) if pcr_pid.is_none() => pcr_pid = pmt.pcr_pid,
Some(TsPayload::PesStart(pes)) => {
units += 1;
let pts = pes.header.pts.expect("PES carried no PTS").as_u64();
let decode = pes.header.dts.map(|t| t.as_u64()).unwrap_or(pts);
if let Some(&pcr) = pcrs.last() {
assert!(decode >= pcr, "unit decodes at {decode}, before the clock at {pcr}");
}
}
_ => {}
}
}
assert!(units > 50, "expected the full feed, got {units} PES units");
assert!(pcrs.len() > 50, "expected a dense clock, got {} PCRs", pcrs.len());
let pcr_pid = pcr_pid.expect("PMT must announce a PCR PID");
assert!(
pcr_pids.iter().all(|&pid| pid == pcr_pid),
"PCR must ride the announced PID"
);
let step = Duration::from_millis(25).as_micros() as u64 * 90 / 1_000;
for (i, w) in pcrs.windows(2).enumerate() {
assert_eq!(w[1] - w[0], step, "PCR interval off the grid at {i}: {w:?}");
}
}
#[tokio::test(start_paused = true)]
async fn export_pcr_wraps_below_the_reserve_at_start() {
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 track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
{
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.audio
.renditions
.insert(track.name().to_string(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
for i in 0..4u64 {
producer
.write(Frame {
timestamp: Timestamp::from_micros(i * 20_000).unwrap(),
duration: None,
payload: Bytes::from_static(&[0x01, 0x02, 0x03, 0x04]),
keyframe: true,
})
.unwrap();
}
producer.finish().unwrap();
let mut exporter = Export::new(crate::source::announced(&consumer))
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
let frames = drain_frames(&mut exporter).await;
let mut pcrs: Vec<u64> = Vec::new();
for (i, frame) in frames.iter().enumerate() {
assert_packet_aligned(&frame.payload);
let mut head = None;
for packet in frame.payload.chunks(188) {
if packet[3] & 0x30 != 0x20 {
break;
}
assert_eq!(packet[5], 0x10, "PCR_flag alone, at {i}");
assert_eq!(packet[10] & 0x7e, 0x7e, "reserved bits must be ones, at {i}");
let base = (u64::from(packet[6]) << 25)
| (u64::from(packet[7]) << 17)
| (u64::from(packet[8]) << 9)
| (u64::from(packet[9]) << 1)
| u64::from(packet[10] >> 7);
pcrs.push(base);
head = Some(base);
}
let Some(base) = head else { continue };
let slot_ticks = frame.timestamp.as_micros() / 25_000 * 25_000 * 90 / 1_000;
assert_eq!(
base,
(slot_ticks as u64).wrapping_sub(16) & WIRE,
"pacing off value, at {i}"
);
}
const WIRE: u64 = (1 << 33) - 1;
assert!(pcrs.len() >= 2, "expected at least two grid slots, got {pcrs:?}");
assert_eq!(pcrs[0], WIRE - 15, "slot 0 backs off through the wrap: {pcrs:?}");
for (i, w) in pcrs.windows(2).enumerate() {
assert_eq!(w[1].wrapping_sub(w[0]) & WIRE, 2250, "step off the grid at {i}: {w:?}");
}
}
#[tokio::test(start_paused = true)]
async fn export_pcr_respects_every_renditions_reserve() {
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 avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let mut make = |name: &str, jitter: Option<Duration>| {
let track = broadcast
.create_track(name, hang::container::track_info(hang::catalog::PRIORITY.video))
.unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc.clone());
cfg.jitter = jitter;
catalog.modify().unwrap().video.renditions.insert(name.to_string(), cfg);
Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data))
};
let mut a = make("a.avc1", None);
let mut b = make("b.avc1", Some(Duration::from_millis(100)));
let idr = [0x65u8; 32];
for i in 0..25u64 {
let timestamp = Timestamp::from_millis(10_000 + i * 40).unwrap();
for video in [&mut a, &mut b] {
video
.write(Frame {
timestamp,
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
}
}
a.finish().unwrap();
b.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut last_pcr = None;
let mut units = 0usize;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(pcr) = packet.adaptation_field.as_ref().and_then(|af| af.pcr) {
last_pcr = Some(pcr.as_u64() / 300);
}
if let Some(TsPayload::PesStart(pes)) = packet.payload {
units += 1;
let pts = pes.header.pts.expect("PES carried no PTS").as_u64();
let decode = pes.header.dts.map(|t| t.as_u64()).unwrap_or(pts);
if let Some(pcr) = last_pcr {
assert!(decode >= pcr, "unit decodes at {decode}, before the clock at {pcr}");
}
}
}
assert!(units >= 50, "expected both renditions' units, got {units}");
}
#[tokio::test(start_paused = true)]
async fn export_pcr_backfills_a_coarse_cadence() {
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 avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
{
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
catalog
.modify()
.unwrap()
.video
.renditions
.insert(track.name().to_string(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let idr = [0x65u8; 32];
for i in 0..10u64 {
producer
.write(Frame {
timestamp: Timestamp::from_millis(10_000 + i * 400).unwrap(),
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
}
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut pcrs: Vec<u64> = Vec::new();
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(pcr) = packet.adaptation_field.as_ref().and_then(|af| af.pcr) {
pcrs.push(pcr.as_u64() / 300);
}
}
assert!(
pcrs.len() > 100,
"expected a dense backfilled clock, got {}",
pcrs.len()
);
for (i, w) in pcrs.windows(2).enumerate() {
assert_eq!(w[1] - w[0], 2250, "PCR interval off the grid at {i}: {w:?}");
}
}
#[tokio::test(start_paused = true)]
async fn export_scte35_roundtrip() {
let data = include_bytes!("test_data/bbb.ts");
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let scte = broadcast
.unique_track(".scte35", hang::container::track_info(hang::catalog::PRIORITY.video))
.unwrap();
let scte_name = scte.name().to_string();
{
let track = tscat::Track {
pid: 0x102,
descriptors: Vec::new(),
verbatim: Some(tscat::Verbatim::new(0x86, tscat::Framing::Section)),
};
catalog
.modify()
.unwrap()
.ext
.mpegts
.tracks
.insert(scte_name.clone(), track);
}
let mut scte_producer = Producer::new(scte, HangContainer::Legacy(crate::container::Kind::Data));
scte_producer
.write(Frame {
timestamp: Timestamp::from_millis(1410).unwrap(),
duration: None,
payload: Bytes::from_static(CUE),
keyframe: true,
})
.unwrap();
scte_producer.cut(None).unwrap();
scte_producer.finish().unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let ts = drain_with(
Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut saw_scte_es = false;
let mut saw_cuei = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pmt(pmt)) = packet.payload {
saw_scte_es = pmt
.es_info
.iter()
.any(|e| e.stream_type == StreamType::Dts8ChannelLosslessAudio);
saw_cuei = pmt
.program_info
.iter()
.any(|d| d.tag == 0x05 && d.data.len() >= 4 && &d.data[0..4] == b"CUEI");
break;
}
}
assert!(saw_scte_es, "PMT missing the SCTE-35 elementary stream (0x86)");
assert!(saw_cuei, "PMT missing the program-level CUEI registration descriptor");
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(
&mut broadcast2,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let snapshot = catalog2.snapshot();
let verbatim = snapshot
.ext
.mpegts
.tracks
.values()
.filter(|t| t.verbatim.is_some())
.count();
assert_eq!(verbatim, 1, "round-trip lost the SCTE-35 track");
let name = scte_track(&snapshot).expect("a scte35 track");
let track = consumer2
.track(&name)
.unwrap()
.subscribe(moq_net::track::Subscription::default().with_max_age(RECORDING_MAX_AGE))
.await
.unwrap();
let mut scte_reader = crate::container::Consumer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let frame = scte_reader
.read()
.await
.unwrap()
.expect("no SCTE-35 frame after round-trip");
assert_eq!(
frame.payload.as_ref(),
CUE,
"SCTE-35 section did not round-trip byte-for-byte"
);
}
#[tokio::test(start_paused = true)]
async fn export_pes_verbatim_roundtrip() {
const DATA_PID: u16 = 0x104;
const STREAM_ID: u8 = 0xc0;
const PAYLOAD: &[u8] = &[0xde, 0xad, 0xbe, 0xef, 0x01, 0x02];
let data = include_bytes!("test_data/bbb.ts");
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let data_track = broadcast
.unique_track(".data", hang::container::track_info(hang::catalog::PRIORITY.video))
.unwrap();
let data_name = data_track.name().to_string();
{
let mut verbatim = tscat::Verbatim::new(0x06, tscat::Framing::Pes);
verbatim.stream_id = Some(STREAM_ID);
let mut track = tscat::Track::new(DATA_PID);
track.verbatim = Some(verbatim);
catalog
.modify()
.unwrap()
.ext
.mpegts
.tracks
.insert(data_name.clone(), track);
}
let mut data_producer = Producer::new(data_track, HangContainer::Legacy(crate::container::Kind::Data));
data_producer
.write(Frame {
timestamp: Timestamp::from_millis(1410).unwrap(),
duration: None,
payload: Bytes::from_static(PAYLOAD),
keyframe: true,
})
.unwrap();
data_producer.cut(None).unwrap();
data_producer.finish().unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let ts = drain_with(
Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(
&mut broadcast2,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let snapshot = catalog2.snapshot();
let (name, track) = snapshot
.ext
.mpegts
.tracks
.iter()
.find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x06))
.expect("verbatim PES survived the round-trip");
assert_eq!(track.pid, DATA_PID, "PES PID preserved");
let verbatim = track.verbatim.as_ref().unwrap();
assert_eq!(verbatim.framing, tscat::Framing::Pes, "PES framing preserved");
assert_eq!(verbatim.stream_id, Some(STREAM_ID), "PES stream_id preserved");
let name = name.clone();
let track = consumer2
.track(&name)
.unwrap()
.subscribe(moq_net::track::Subscription::default().with_max_age(RECORDING_MAX_AGE))
.await
.unwrap();
let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let frame = reader
.read()
.await
.unwrap()
.expect("no verbatim PES frame after round-trip");
assert_eq!(
frame.payload.as_ref(),
PAYLOAD,
"verbatim PES payload round-trips byte-for-byte"
);
}
#[tokio::test(start_paused = true)]
async fn scte35_without_video_export_is_rejected() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let scte = broadcast
.unique_track(".scte35", hang::container::track_info(hang::catalog::PRIORITY.video))
.unwrap();
let scte_name = scte.name().to_string();
{
let track = tscat::Track {
pid: 0x102,
descriptors: Vec::new(),
verbatim: Some(tscat::Verbatim::new(0x86, tscat::Framing::Section)),
};
catalog.modify().unwrap().ext.mpegts.tracks.insert(scte_name, track);
}
let mut producer = Producer::new(scte, HangContainer::Legacy(crate::container::Kind::Data));
producer
.write(Frame {
timestamp: Timestamp::from_millis(0).unwrap(),
duration: None,
payload: Bytes::from_static(CUE),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
producer.finish().unwrap();
let mut exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap();
let err = loop {
match tokio::time::timeout(Duration::from_secs(1), exporter.next()).await {
Ok(Ok(Some(_))) => continue,
Ok(Ok(None)) => panic!("export completed; a cue program without video must be rejected"),
Ok(Err(e)) => break e,
Err(_) => panic!("export neither errored nor completed"),
}
};
assert!(
err.to_string().contains("requires a video track"),
"expected a video-required rejection, got: {err}"
);
}
async fn read_frames(consumer: &moq_net::broadcast::Consumer, name: &str, kind: Kind) -> Vec<Vec<u8>> {
let track = consumer
.track(name)
.unwrap()
.subscribe(moq_net::track::Subscription::default().with_max_age(RECORDING_MAX_AGE))
.await
.unwrap();
let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(kind));
let mut frames = Vec::new();
while let Ok(res) = tokio::time::timeout(Duration::from_millis(50), reader.read()).await {
let Some(frame) = res.unwrap() else { break };
frames.push(frame.payload.to_vec());
}
frames
}
#[tokio::test(start_paused = true)]
async fn mp2_kyrion_roundtrip_byte_exact() {
let data = include_bytes!("test_data/scte35/kyrion_dirtystart.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let names: Vec<String> = catalog.snapshot().audio.renditions.keys().cloned().collect();
assert_eq!(names.len(), 2, "both Kyrion MP2 programs");
let mut ingested = Vec::new();
for name in &names {
let frames = read_frames(&consumer, name, Kind::Audio).await;
assert!(!frames.is_empty(), "{name}: no MP2 frames");
assert!(
frames.iter().all(|f| f[0] == 0xFF && f[1] & 0xE0 == 0xE0),
"{name}: whole-frame carriage starts at the Layer II sync word"
);
ingested.push(frames);
}
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pmt(pmt)) = packet.payload {
let mp2 = pmt
.es_info
.iter()
.filter(|e| e.stream_type == StreamType::Mpeg1Audio)
.count();
assert_eq!(mp2, 2, "PMT must re-announce both MP2 streams as 0x03");
break;
}
}
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(&mut broadcast2, crate::catalog::Config::default()).unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let names2: Vec<String> = catalog2.snapshot().audio.renditions.keys().cloned().collect();
assert_eq!(names2.len(), 2, "round-trip lost an MP2 track");
let mut roundtripped = Vec::new();
for name in &names2 {
roundtripped.push(read_frames(&consumer2, name, Kind::Audio).await);
}
for rt in &roundtripped {
assert!(!rt.is_empty(), "a program lost all of its MP2 frames");
assert!(
ingested.iter().any(|ing| ing.ends_with(rt)),
"round-tripped MP2 must be a byte-exact suffix of an ingested program"
);
}
}
#[tokio::test(start_paused = true)]
async fn ac3_roundtrip_byte_exact() {
let data = include_bytes!("test_data/ac3.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let name = catalog
.snapshot()
.audio
.renditions
.keys()
.next()
.expect("an AC-3 track")
.clone();
let ingested = read_frames(&consumer, &name, Kind::Audio).await;
assert!(!ingested.is_empty(), "no AC-3 frames");
assert!(
ingested.iter().all(|f| f[0] == 0x0B && f[1] == 0x77),
"whole-frame carriage starts at the AC-3 sync word"
);
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut checked_pmt = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pmt(pmt)) = packet.payload {
assert_eq!(pmt.es_info.len(), 1);
assert_eq!(pmt.es_info[0].stream_type, StreamType::DolbyDigitalUpToSixChannelAudio);
assert!(
pmt.es_info[0]
.descriptors
.iter()
.any(|d| d.tag == 0x05 && d.data.as_slice() == b"AC-3"),
"PMT missing the ES-level 'AC-3' registration descriptor"
);
checked_pmt = true;
break;
}
}
assert!(checked_pmt, "missing PMT");
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(&mut broadcast2, crate::catalog::Config::default()).unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let name2 = catalog2
.snapshot()
.audio
.renditions
.keys()
.next()
.expect("round-trip lost the AC-3 track")
.clone();
let roundtripped = read_frames(&consumer2, &name2, Kind::Audio).await;
assert_eq!(roundtripped, ingested, "AC-3 frames must survive byte-for-byte");
}
fn first_adts_frame(ts: &[u8]) -> (super::adts::Header, Vec<u8>) {
let mut pes = PesPacketReader::new(TsPacketReader::new(Cursor::new(ts)));
let packet = pes.read_pes_packet().unwrap().expect("an AAC PES");
let header = super::adts::Header::parse(&packet.data).unwrap();
(header, packet.data[header.header_len..header.frame_len].to_vec())
}
#[tokio::test(start_paused = true)]
async fn aac_program_config_roundtrip() {
let data = include_bytes!("test_data/aac_quad.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let snapshot = catalog.snapshot();
let (name, audio) = snapshot.audio.renditions.iter().next().expect("an AAC track");
assert_eq!(audio.channel_count, 4);
let ingested = read_frames(&consumer, name, Kind::Audio).await;
assert!(!ingested.is_empty(), "no AAC frames");
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let (header, block) = first_adts_frame(&ts);
assert_eq!(header.channel_config, 0, "the layout is not a channelConfiguration");
assert_eq!(
block,
first_adts_frame(data).1,
"the first raw data block, element and all"
);
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(&mut broadcast2, crate::catalog::Config::default()).unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let snapshot2 = catalog2.snapshot();
let (name2, audio2) = snapshot2
.audio
.renditions
.iter()
.next()
.expect("round-trip lost the AAC track");
assert_eq!(audio2.channel_count, 4);
assert_eq!(audio2.description, audio.description);
let roundtripped = read_frames(&consumer2, name2, Kind::Audio).await;
assert_eq!(roundtripped, ingested, "the element is written once, not per frame");
}
#[tokio::test(start_paused = true)]
async fn aac_explicit_sbr_exports_its_lc_core() {
let fixtures: [(&[u8], u8); 2] = [
(include_bytes!("test_data/he_aac.flv"), 2),
(include_bytes!("test_data/he_aac_v2.flv"), 1),
];
for (data, channel_config) in fixtures {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::flv::Import::new(broadcast, catalog.reserve());
import.decode(data).unwrap();
import.finish().unwrap();
let snapshot = catalog.snapshot();
let (name, _) = snapshot.audio.renditions.iter().next().expect("an AAC track");
let ingested = read_frames(&consumer, name, Kind::Audio).await;
assert!(!ingested.is_empty(), "no AAC frames");
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let (header, block) = first_adts_frame(&ts);
assert_eq!(header.object_type, 2, "the LC core, not a masked SBR or PS");
assert_eq!(header.sample_rate, 24_000, "the core rate, not the output rate");
assert_eq!(header.channel_config, channel_config);
assert_eq!(block, ingested[0], "the raw data block is untouched");
}
}
#[tokio::test(start_paused = true)]
async fn aac_export_refuses_what_adts_cannot_label() {
for (profile, channel_count, refusal) in [(2, 7, "7 channels"), (5, 2, "audioObjectType 5")] {
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 track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let mut cfg = AudioConfig::new(AAC { profile }, 48_000, channel_count);
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.audio
.renditions
.insert(track.name().to_string(), cfg);
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
producer
.write(Frame {
timestamp: Timestamp::from_millis(0).unwrap(),
duration: None,
payload: Bytes::from_static(&[0x01, 0x02]),
keyframe: true,
})
.unwrap();
producer.finish().unwrap();
let mut exporter = Export::new(crate::source::announced(&consumer)).await.unwrap();
let err = loop {
match tokio::time::timeout(Duration::from_secs(1), exporter.next()).await {
Ok(Ok(Some(_))) => continue,
Ok(Ok(None)) => panic!("export completed; expected a refusal naming {refusal}"),
Ok(Err(e)) => break e,
Err(_) => panic!("export neither errored nor completed"),
}
};
assert!(err.to_string().contains(refusal), "expected {refusal}, got: {err}");
}
}
#[tokio::test(start_paused = true)]
async fn eac3_roundtrip_byte_exact() {
let data = include_bytes!("test_data/eac3.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let name = catalog
.snapshot()
.audio
.renditions
.keys()
.next()
.expect("an E-AC-3 track")
.clone();
let ingested = read_frames(&consumer, &name, Kind::Audio).await;
assert!(!ingested.is_empty(), "no E-AC-3 frames");
assert!(
ingested.iter().all(|f| f[0] == 0x0B && f[1] == 0x77),
"whole-frame carriage starts at the E-AC-3 sync word"
);
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut checked_pmt = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pmt(pmt)) = packet.payload {
assert_eq!(pmt.es_info.len(), 1);
assert_eq!(
pmt.es_info[0].stream_type,
StreamType::DolbyDigitalPlusUpTo16ChannelAudioForAtsc
);
assert!(
pmt.es_info[0]
.descriptors
.iter()
.any(|d| d.tag == 0x05 && d.data.as_slice() == b"EAC3"),
"PMT missing the ES-level 'EAC3' registration descriptor"
);
checked_pmt = true;
break;
}
}
assert!(checked_pmt, "missing PMT");
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(&mut broadcast2, crate::catalog::Config::default()).unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let name2 = catalog2
.snapshot()
.audio
.renditions
.keys()
.next()
.expect("round-trip lost the E-AC-3 track")
.clone();
let roundtripped = read_frames(&consumer2, &name2, Kind::Audio).await;
assert_eq!(roundtripped, ingested, "E-AC-3 frames must survive byte-for-byte");
}
async fn read_audio_by_codec(
consumer: &moq_net::broadcast::Consumer,
catalog: &crate::catalog::Producer,
) -> std::collections::BTreeMap<String, Vec<Vec<u8>>> {
let mut out = std::collections::BTreeMap::new();
for (name, config) in &catalog.snapshot().audio.renditions {
out.insert(config.codec.to_string(), read_frames(consumer, name, Kind::Audio).await);
}
out
}
#[tokio::test(start_paused = true)]
async fn kyrion_ac3_mp2_roundtrip_byte_exact() {
let data = include_bytes!("test_data/kyrion_mpeg2av_ac3.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let ingested = read_audio_by_codec(&consumer, &catalog).await;
assert_eq!(
ingested.keys().cloned().collect::<Vec<_>>(),
["ac-3", "mp2"],
"both real audio codecs cataloged"
);
assert!(
ingested["ac-3"].iter().all(|f| f[0] == 0x0B && f[1] == 0x77),
"AC-3 whole-frame carriage"
);
assert!(
ingested["mp2"].iter().all(|f| f[0] == 0xFF && f[1] & 0xE0 == 0xE0),
"MP2 whole-frame carriage"
);
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pmt(pmt)) = packet.payload {
assert_eq!(pmt.es_info.len(), 2, "audio-only program: AC-3 + MP2");
let ac3 = pmt
.es_info
.iter()
.find(|e| e.stream_type == StreamType::DolbyDigitalUpToSixChannelAudio)
.expect("AC-3 ES re-announced as 0x81");
assert!(
ac3.descriptors
.iter()
.any(|d| d.tag == 0x05 && d.data.as_slice() == b"AC-3"),
"AC-3 registration descriptor"
);
assert!(
pmt.es_info.iter().any(|e| e.stream_type == StreamType::Mpeg1Audio),
"MP2 re-announced as 0x03 (48 kHz is an MPEG-1 rate)"
);
break;
}
}
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(&mut broadcast2, crate::catalog::Config::default()).unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let roundtripped = read_audio_by_codec(&consumer2, &catalog2).await;
assert_eq!(roundtripped, ingested, "both audio streams survive byte-for-byte");
}
fn scte_track(snap: &crate::catalog::hang::Catalog<tscat::Ext>) -> Option<String> {
snap.ext
.mpegts
.tracks
.iter()
.find(|(_, t)| t.verbatim.as_ref().is_some_and(|v| v.stream_type == 0x86))
.map(|(name, _)| name.clone())
}
async fn read_cues(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec<(Vec<u8>, Timestamp)> {
let track = consumer
.track(name)
.unwrap()
.subscribe(moq_net::track::Subscription::default().with_max_age(RECORDING_MAX_AGE))
.await
.unwrap();
let mut reader = crate::container::Consumer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut cues = Vec::new();
while let Ok(res) = tokio::time::timeout(Duration::from_millis(50), reader.read()).await {
let Some(frame) = res.unwrap() else { break };
cues.push((frame.payload.to_vec(), frame.timestamp));
}
cues
}
#[tokio::test(start_paused = true)]
async fn scte35_fixtures_survive_roundtrip() {
type Fixture = (&'static str, usize, usize, &'static [u8], &'static [u8]);
let fixtures: &[Fixture] = &[
("ffmpeg", 5, 5, &[], include_bytes!("test_data/scte35/ffmpeg.ts")),
("gst480i", 5, 5, &[], include_bytes!("test_data/scte35/gst480i.ts")),
("bbb5s", 5, 5, &[], include_bytes!("test_data/scte35/bbb5s.ts")),
(
"tsduck",
10,
5,
&[0x00, 0x05, 0x06, 0xff],
include_bytes!("test_data/scte35/tsduck.ts"),
),
(
"kyrion_dirtystart",
6,
6,
&[],
include_bytes!("test_data/scte35/kyrion_dirtystart.ts"),
),
];
const KNOWN_SPLICE_COMMANDS: [u8; 6] = [0x00, 0x04, 0x05, 0x06, 0x07, 0xff];
for (source, total, distinct, command_types, data) in fixtures {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let snap = catalog.snapshot();
assert!(!snap.video.renditions.is_empty(), "{source}: video track from the clip");
let name = scte_track(&snap).expect("a scte35 track");
let ingested = read_cues(&consumer, &name).await;
assert_eq!(ingested.len(), *total, "{source}: {total} cues on ingest");
assert!(
ingested.iter().all(|(b, _)| b.first() == Some(&0xfc)),
"{source}: every cue is a splice_info_section (table_id 0xFC)"
);
let unique: std::collections::HashSet<&Vec<u8>> = ingested.iter().map(|(b, _)| b).collect();
assert_eq!(
unique.len(),
*distinct,
"{source}: {distinct} distinct cue sections, not dups"
);
assert!(
ingested
.iter()
.all(|(b, _)| b.get(13).is_some_and(|t| KNOWN_SPLICE_COMMANDS.contains(t))),
"{source}: every cue carries a known splice_command_type"
);
if !command_types.is_empty() {
let mut got: Vec<u8> = ingested.iter().filter_map(|(b, _)| b.get(13).copied()).collect();
got.sort_unstable();
got.dedup();
assert_eq!(got.as_slice(), *command_types, "{source}: splice_command_type set");
}
assert!(
ingested.iter().all(|(_, ts)| *ts != Timestamp::ZERO),
"{source}: cues stamped with the video PTS, not zero"
);
let ts = drain_with(
Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(
&mut broadcast2,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let name2 = scte_track(&catalog2.snapshot()).expect("a scte35 track");
let roundtripped = read_cues(&consumer2, &name2).await;
let before: Vec<&Vec<u8>> = ingested.iter().map(|(b, _)| b).collect();
let after: Vec<&Vec<u8>> = roundtripped.iter().map(|(b, _)| b).collect();
assert_eq!(
after, before,
"{source}: every section survived TS -> MoQ -> TS byte-for-byte"
);
}
}
fn make_long_section(table_id: u8, ext: u16, version: u8, number: u8, last: u8, body: &[u8]) -> Vec<u8> {
let section_length = 5 + body.len() + 4;
let mut s = vec![
table_id,
0xb0 | ((section_length >> 8) as u8 & 0x0f),
(section_length & 0xff) as u8,
(ext >> 8) as u8,
(ext & 0xff) as u8,
0xc0 | (version << 1) | 0x01,
number,
last,
];
s.extend_from_slice(body);
let crc = crc::Crc::<u32>::new(&crc::CRC_32_MPEG_2).checksum(&s);
s.extend_from_slice(&crc.to_be_bytes());
s
}
async fn read_si_groups(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec<Vec<Bytes>> {
let mut track = consumer
.track(name)
.unwrap()
.subscribe(
moq_net::track::Subscription::default()
.with_start(moq_net::track::Position::group(0))
.with_max_age(moq_net::track::DEFAULT_MAX_AGE),
)
.await
.unwrap();
let mut groups = Vec::new();
while let Some(mut group) = track.recv_group().await.unwrap() {
let mut frames = Vec::new();
while let Some(frame) = kio::wait(|waiter| group.poll_read_frame(waiter)).await.unwrap() {
frames.push(frame.payload);
}
groups.push(frames);
}
groups
}
async fn read_si_sections(consumer: &moq_net::broadcast::Consumer, name: &str) -> Vec<Bytes> {
let groups = read_si_groups(consumer, name).await;
let frames = groups.last().expect("at least one snapshot group");
frames
.iter()
.flat_map(crate::container::ts::si::split_sections)
.collect()
}
fn si_packet(pid: u16, section: &[u8]) -> Vec<u8> {
si_packet_cc(pid, section, 0)
}
fn si_packet_cc(pid: u16, section: &[u8], cc: u8) -> Vec<u8> {
let mut p = vec![
0x47,
0x40 | ((pid >> 8) as u8 & 0x1f),
(pid & 0xff) as u8,
0x10 | (cc & 0x0f),
0x00,
];
p.extend_from_slice(section);
assert!(p.len() <= 188, "section overflows one TS packet");
p.resize(188, 0xff);
p
}
fn si_packets_multi(pid: u16, section: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut cc = 0u8;
let mut first = vec![
0x47,
0x40 | ((pid >> 8) as u8 & 0x1f),
(pid & 0xff) as u8,
0x10 | cc,
0x00,
];
let head = section.len().min(188 - first.len());
first.extend_from_slice(§ion[..head]);
first.resize(188, 0xff);
out.extend_from_slice(&first);
let mut pos = head;
while pos < section.len() {
cc = (cc + 1) & 0x0f;
let mut p = vec![0x47, (pid >> 8) as u8 & 0x1f, (pid & 0xff) as u8, 0x10 | cc];
let take = (section.len() - pos).min(188 - p.len());
p.extend_from_slice(§ion[pos..pos + take]);
p.resize(188, 0xff);
out.extend_from_slice(&p);
pos += take;
}
out
}
fn parse_sdt_service(sec: &[u8]) -> (u8, String, String) {
let desc_loop_len = (((sec[11 + 3] & 0x0f) as usize) << 8) | sec[11 + 4] as usize;
let mut d = 11 + 5;
let end = d + desc_loop_len;
while d < end {
let (tag, len) = (sec[d], sec[d + 1] as usize);
let body = &sec[d + 2..d + 2 + len];
if tag == 0x48 {
let service_type = body[0];
let prov_len = body[1] as usize;
let provider = String::from_utf8_lossy(&body[2..2 + prov_len]).into_owned();
let name_len = body[2 + prov_len] as usize;
let name = String::from_utf8_lossy(&body[3 + prov_len..3 + prov_len + name_len]).into_owned();
return (service_type, provider, name);
}
d += 2 + len;
}
panic!("SDT service_descriptor (0x48) not found");
}
#[tokio::test(start_paused = true)]
async fn service_layer_survives_roundtrip() {
let data = include_bytes!("test_data/bbb.ts");
let nit = make_long_section(0x40, 0x1234, 1, 0, 0, &[0xff, 0x01]);
let mut input = si_packet(0x0010, &nit);
input.extend_from_slice(&si_packet_cc(0x0010, &nit, 1));
input.extend_from_slice(&data[..]);
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&input[..])).unwrap();
import.finish().unwrap();
let snapshot = catalog.snapshot();
let program = snapshot.ext.mpegts.program.clone().expect("a program record");
assert_eq!(program.transport_stream_id, 1, "TSID captured from the PAT");
assert_eq!(program.program_number, 1, "program number captured from the PAT");
assert_eq!(program.pmt_pid, 0x1000, "original PMT PID captured from the PAT");
let si = snapshot.ext.mpegts.si.clone();
let entry = |pid: u16, table_id: u8| {
si.get(&pid)
.and_then(|tables| tables.get(&table_id))
.expect("an SI entry for the (PID, table_id)")
};
let sdt_entry = entry(0x0011, 0x42);
assert_eq!(
sdt_entry.interval,
Some(Duration::from_secs(2)),
"the DVB SDT interval was filled in"
);
let sdt_sections = read_si_sections(&consumer, &sdt_entry.track).await;
assert_eq!(sdt_sections.len(), 1, "bbb.ts carries one SDT section");
let sdt = sdt_sections[0].clone();
assert_eq!(sdt.first(), Some(&0x42), "SDT Actual (table_id 0x42)");
let (service_type, provider, name) = parse_sdt_service(&sdt);
assert_eq!(
(service_type, provider.as_str(), name.as_str()),
(0x01, "FFmpeg", "Service01")
);
let nit_entry = entry(0x0010, 0x40);
assert_eq!(
nit_entry.interval,
Some(Duration::from_secs(10)),
"the DVB NIT interval was filled in"
);
let nit_groups = read_si_groups(&consumer, &nit_entry.track).await;
assert_eq!(
nit_groups.len(),
1,
"the repeated NIT committed once; a repetition cuts no group"
);
assert_eq!(
nit_groups[0],
vec![Bytes::from(nit.clone())],
"the NIT snapshot is the section, byte-for-byte"
);
let ts = drain_with(
Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut checked_pat = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pat(pat)) = packet.payload {
assert_eq!(pat.transport_stream_id, 1, "TSID preserved in the rebuilt PAT");
assert_eq!(pat.table.len(), 1);
assert_eq!(pat.table[0].program_num, 1, "service number preserved");
assert_eq!(pat.table[0].program_map_pid.as_u16(), 0x1000, "PMT PID preserved");
checked_pat = true;
break;
}
}
assert!(checked_pat, "missing PAT");
let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(
&mut broadcast2,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let snapshot2 = catalog2.snapshot();
let program2 = snapshot2
.ext
.mpegts
.program
.clone()
.expect("a program record after round-trip");
assert_eq!(
program2.transport_stream_id, program.transport_stream_id,
"TSID survived"
);
assert_eq!(
program2.program_number, program.program_number,
"program number survived"
);
assert_eq!(program2.pmt_pid, program.pmt_pid, "PMT PID survived");
assert_eq!(snapshot2.ext.mpegts.si, si, "every SI entry survived the round-trip");
assert_eq!(
read_si_sections(&consumer2, &snapshot2.ext.mpegts.si[&0x0011][&0x42].track).await,
vec![sdt],
"the SDT survived byte-for-byte"
);
assert_eq!(
read_si_sections(&consumer2, &snapshot2.ext.mpegts.si[&0x0010][&0x40].track).await,
vec![Bytes::from(nit)],
"the NIT survived byte-for-byte"
);
}
#[tokio::test(start_paused = true)]
async fn si_pids_are_re_emitted_on_their_own_interval() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
let mut sdt_track = broadcast.create_track("0x0011-0x42.si", None).unwrap();
sdt_track
.write_frame(
Timestamp::ZERO,
Bytes::from(make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8])),
)
.unwrap();
let mut nit_track = broadcast.create_track("0x0010-0x40.si", None).unwrap();
nit_track
.write_frame(
Timestamp::ZERO,
Bytes::from(make_long_section(0x40, 1, 0, 0, 0, &[0xbb; 8])),
)
.unwrap();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name.clone(), cfg);
guard.ext.mpegts.si.entry(0x0011).or_default().insert(
0x42,
tscat::SiEntry {
track: "0x0011-0x42.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
guard.ext.mpegts.si.entry(0x0010).or_default().insert(
0x40,
tscat::SiEntry {
track: "0x0010-0x40.si".to_string(),
interval: Some(Duration::from_secs(10)),
..Default::default()
},
);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
for sec in 0..=12u64 {
producer
.write(Frame {
timestamp: Timestamp::from_micros(sec * 1_000_000).unwrap(),
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
}
producer.finish().unwrap();
let ts = drain_with(
Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let count = |pid: u16| {
ts.as_chunks::<188>()
.0
.iter()
.filter(|p| ((((p[1] & 0x1f) as u16) << 8) | p[2] as u16) == pid)
.count()
};
assert_eq!(count(0x0000), 13, "PAT on every frame");
assert_eq!(count(0x0011), 7, "SDT re-emitted on its 2s interval");
assert_eq!(count(0x0010), 2, "NIT re-emitted on its 10s interval");
}
#[tokio::test(start_paused = true)]
async fn inline_si_form_is_re_emitted() {
use base64::Engine;
let broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
let mut catalog = crate::catalog::hang::Catalog::<tscat::Ext>::default();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
catalog.video.renditions.insert(name.clone(), cfg);
let mut json = serde_json::to_value(&catalog).unwrap();
let sdt = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8]);
let inline = base64::engine::general_purpose::STANDARD.encode(&sdt);
json["mpegts"] = serde_json::json!({"si": {"17": {"interval": 2000, "sections": [inline]}}});
let mut catalog_track = broadcast
.create_track(hang::Catalog::DEFAULT_NAME, hang::Catalog::default_track_info())
.unwrap();
catalog_track
.write_frame(Timestamp::ZERO, Bytes::from(serde_json::to_vec(&json).unwrap()))
.unwrap();
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
for sec in 0..=12u64 {
producer
.write(Frame {
timestamp: Timestamp::from_micros(sec * 1_000_000).unwrap(),
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
}
producer.finish().unwrap();
let ts = drain_with(
Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let count = |pid: u16| {
ts.as_chunks::<188>()
.0
.iter()
.filter(|p| ((((p[1] & 0x1f) as u16) << 8) | p[2] as u16) == pid)
.count()
};
assert_eq!(count(0x0000), 13, "PAT on every frame");
assert_eq!(count(0x0011), 7, "inline SDT re-emitted on its 2s interval");
let packet = ts
.as_chunks::<188>()
.0
.iter()
.find(|p| ((((p[1] & 0x1f) as u16) << 8) | p[2] as u16) == 0x0011)
.unwrap();
assert_eq!(
&packet[188 - sdt.len()..],
&sdt[..],
"the inline SDT rides its PID: {packet:02x?}"
);
}
fn count_pid(frames: &[Frame], pid: u16) -> usize {
frames
.iter()
.flat_map(|f| f.payload.as_chunks::<188>().0.iter())
.filter(|p| p[3] & 0x10 != 0 && ((((p[1] & 0x1f) as u16) << 8) | p[2] as u16) == pid)
.count()
}
fn count_discontinuity(frames: &[Frame]) -> usize {
frames
.iter()
.flat_map(|f| f.payload.as_chunks::<188>().0.iter())
.filter(|p| p[3] & 0x20 != 0 && p[4] > 0 && p[5] & 0x80 != 0)
.count()
}
#[tokio::test(start_paused = true)]
async fn export_rejects_si_pid_on_the_pmt() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
let mut guard = catalog.modify().unwrap();
guard.audio.renditions.insert(name, cfg);
guard.ext.mpegts.program = Some(tscat::Program {
transport_stream_id: 1,
program_number: 1,
pmt_pid: 0x1000,
..Default::default()
});
guard.ext.mpegts.si.entry(0x1000).or_default().insert(
0x42,
tscat::SiEntry {
track: "si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
}
let _producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
let err = exporter
.next()
.await
.expect_err("an SI PID on the PMT must fail the export");
assert!(
err.to_string().contains("mpegts.si PID"),
"expected a PID collision, got {err}"
);
}
#[tokio::test(start_paused = true)]
async fn export_rejects_si_pid_on_an_elementary_stream() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
let mut guard = catalog.modify().unwrap();
guard.audio.renditions.insert(name.clone(), cfg);
guard.ext.mpegts.tracks.insert(name, tscat::Track::new(0x101));
guard.ext.mpegts.si.entry(0x101).or_default().insert(
0x42,
tscat::SiEntry {
track: "si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
}
let _producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
let err = exporter
.next()
.await
.expect_err("an SI PID on an elementary stream must fail the export");
assert!(
err.to_string().contains("mpegts.si PID"),
"expected a PID collision, got {err}"
);
}
async fn export_of(consumer: &moq_net::broadcast::Consumer) -> Export<tscat::Ext> {
Export::with_ts(crate::source::announced(consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE)
}
fn publish_sdt(
broadcast: &mut moq_net::broadcast::Producer,
catalog: &mut crate::catalog::Producer<tscat::Ext>,
) -> moq_net::track::Producer {
let mut track = broadcast.create_track("0x0011-0x42.si", None).unwrap();
track
.write_frame(
Timestamp::ZERO,
Bytes::from(make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8])),
)
.unwrap();
catalog
.modify()
.unwrap()
.ext
.mpegts
.si
.entry(0x0011)
.or_default()
.insert(
0x42,
tscat::SiEntry {
track: track.name().to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
track
}
#[tokio::test(start_paused = true)]
async fn discontinuity_re_emits_tables_and_resumes_the_clock() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let name = track.name().to_string();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
guard.audio.renditions.insert(name, cfg);
}
let _sdt = publish_sdt(&mut broadcast, &mut catalog);
let write = |producer: &mut Producer<HangContainer>, count: u64, offset: u64| {
for i in 0..count {
producer
.write(Frame {
timestamp: Timestamp::from_micros(offset + i * 100_000).unwrap(),
duration: None,
payload: Bytes::from_iter((0..180u16).map(|b| (b ^ i as u16) as u8)),
keyframe: i % 10 == 0,
})
.unwrap();
if i % 10 == 9 {
producer.cut(None).unwrap();
}
}
};
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Audio));
let mut export = export_of(&consumer).await;
write(&mut producer, 50, 600_000_000);
let before = drain_frames(&mut export).await;
assert_eq!(export.discontinuity(), 0, "no rewind yet");
assert_eq!(count_pid(&before, 0x0000), 10, "PAT on its 500ms cadence");
assert_eq!(count_pid(&before, 0x0011), 3, "SDT at 0, 2 and 4s");
producer.discontinuity().unwrap();
write(&mut producer, 20, 605_000_000);
producer.cut(None).unwrap();
let marked = drain_frames(&mut export).await;
let tail: Vec<_> = before
.iter()
.chain(&marked)
.flat_map(|f| f.payload.iter().copied())
.collect();
let (_, audio_pts) = collect_pes_pts(&tail);
let mut reader = TsPacketReader::new(Cursor::new(tail));
let mut preserved = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::PesStart(pes)) = packet.payload {
preserved |= pes.header.pts.unwrap().as_u64() == 604_900_000 * 90 / 1000;
}
}
assert!(preserved, "forward marker discarded the pre-boundary tail");
assert!(!audio_pts.is_empty());
assert_eq!(export.discontinuity(), 1, "the marker was observed");
let resume = marked.iter().position(|f| f.timestamp.as_micros() >= 605_000_000);
let resume = resume.unwrap_or_else(|| {
panic!(
"resumed media missing, marked timestamps {:?}",
marked.iter().map(|f| f.timestamp.as_micros()).collect::<Vec<_>>()
)
});
assert_eq!(
marked[resume].payload[3] & 0x30,
0x20,
"the new timeline leads with its clock"
);
let pcrs = collect_pcrs(&marked[resume..]);
assert!(!pcrs.is_empty(), "clock resumed promptly");
for pair in pcrs.windows(2) {
assert_eq!(pair[1].1.wrapping_sub(pair[0].1) & ((1 << 33) - 1), 2250);
}
assert!(
count_pid(&marked[resume..], 0x0000) >= 1,
"PAT re-emitted after the marker"
);
assert_eq!(count_discontinuity(&before), 0);
assert_eq!(count_discontinuity(&marked), 1, "the break is flagged exactly once");
}
#[tokio::test(start_paused = true)]
async fn reordered_video_keeps_the_table_cadence() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name, cfg);
}
let _sdt = publish_sdt(&mut broadcast, &mut catalog);
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
let mut slice = vec![0x41u8];
slice.extend(std::iter::repeat_n(0xCD, 200));
for quad in 0..32u64 {
for offset in [0, 3, 1, 2] {
let index = quad * 4 + offset;
if index >= 125 {
continue;
}
let keyframe = index == 0;
producer
.write(Frame {
timestamp: Timestamp::from_micros(index * 40_000).unwrap(),
duration: None,
payload: length_prefixed(&[if keyframe { idr.as_slice() } else { slice.as_slice() }]),
keyframe,
})
.unwrap();
}
}
let mut export = export_of(&consumer).await;
let out = drain_frames(&mut export).await;
assert_eq!(export.discontinuity(), 0, "a reorder is not a rewind");
assert_eq!(count_pid(&out, 0x0000), 10, "PAT once per 500ms slot, not per reorder");
assert_eq!(count_pid(&out, 0x0011), 3, "SDT once per 2s slot, not per reorder");
assert_eq!(
count_discontinuity(&out),
1,
"only the reserve's growth restarts the clock"
);
}
#[tokio::test(start_paused = true)]
async fn discontinuity_flags_the_break_once_across_tracks() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let video_track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let audio_track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
{
let mut guard = catalog.modify().unwrap();
let mut video = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
video.container = Container::Legacy;
video.description = Some(avcc);
guard.video.renditions.insert(video_track.name().to_string(), video);
let mut audio = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
audio.container = Container::Legacy;
guard.audio.renditions.insert(audio_track.name().to_string(), audio);
}
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 300));
let mut video = Producer::new(video_track, HangContainer::Legacy(crate::container::Kind::Video));
let mut audio = Producer::new(audio_track, HangContainer::Legacy(crate::container::Kind::Audio));
let write = |video: &mut Producer<HangContainer>, audio: &mut Producer<HangContainer>, start: u64, seconds: u64| {
for sec in start..start + seconds {
video
.write(Frame {
timestamp: Timestamp::from_micros(sec * 1_000_000).unwrap(),
duration: None,
payload: length_prefixed(&[idr.as_slice()]),
keyframe: true,
})
.unwrap();
video.cut(None).unwrap();
for tenth in 0..10u64 {
audio
.write(Frame {
timestamp: Timestamp::from_micros(sec * 1_000_000 + tenth * 100_000).unwrap(),
duration: None,
payload: Bytes::from_iter((0..180u16).map(|b| (b ^ tenth as u16) as u8)),
keyframe: tenth == 0,
})
.unwrap();
}
audio.cut(None).unwrap();
}
};
let mut export = export_of(&consumer).await;
write(&mut video, &mut audio, 0, 4);
let before = drain_frames(&mut export).await;
assert_eq!(export.discontinuity(), 0);
assert_eq!(count_discontinuity(&before), 0);
for _ in 0..3 {
video.discontinuity().unwrap();
}
video
.write(Frame {
timestamp: Timestamp::from_micros(4_000_000).unwrap(),
duration: None,
payload: length_prefixed(&[idr.as_slice()]),
keyframe: true,
})
.unwrap();
video.cut(None).unwrap();
video
.write(Frame {
timestamp: Timestamp::from_micros(4_100_000).unwrap(),
duration: None,
payload: length_prefixed(&[idr.as_slice()]),
keyframe: true,
})
.unwrap();
video.cut(None).unwrap();
let mut marked = drain_frames(&mut export).await;
for _ in 0..2 {
tokio::time::sleep(RECORDING_MAX_AGE).await;
marked.extend(drain_frames(&mut export).await);
}
assert_eq!(export.discontinuity(), 1, "local marker counts are not program epochs");
let epoch = export.discontinuity();
video.discontinuity().unwrap();
audio.discontinuity().unwrap();
write(&mut video, &mut audio, 5, 2);
let after = drain_frames(&mut export).await;
assert_eq!(
export.discontinuity(),
epoch + 1,
"one break, however many tracks saw it"
);
assert_eq!(
after[0].payload[3] & 0x30,
0x20,
"the new timeline leads with its clock"
);
assert_eq!(count_discontinuity(&after), 1, "the break is flagged exactly once");
let bytes: Vec<_> = after.iter().flat_map(|f| f.payload.iter().copied()).collect();
let (video_pts, audio_pts) = collect_pes_pts(&bytes);
assert!(!video_pts.is_empty(), "video resumed");
assert!(!audio_pts.is_empty(), "audio resumed");
let mut reader = TsPacketReader::new(Cursor::new(bytes));
let mut video_pid = None;
let mut video_frames = 0;
while let Some(packet) = reader.read_ts_packet().unwrap() {
match packet.payload {
Some(TsPayload::Pmt(pmt)) => {
video_pid = pmt
.es_info
.iter()
.find(|es| es.stream_type == StreamType::H264)
.map(|es| es.elementary_pid.as_u16());
}
Some(TsPayload::PesStart(pes)) if Some(packet.header.pid.as_u16()) == video_pid => {
let pts = pes.header.pts.unwrap().as_u64();
assert!(
pes.header.dts.is_none_or(|dts| dts.as_u64() <= pts),
"DTS retained the old epoch"
);
video_frames += 1;
}
_ => {}
}
}
assert!(video_frames > 0);
let video_pid = video_pid.expect("video PID in PMT");
write(&mut video, &mut audio, 7, 2);
let again = drain_frames(&mut export).await;
assert!(!again.is_empty(), "both tracks kept emitting across the join");
assert!(count_pid(&again, video_pid) > 0, "video was not fenced");
assert_eq!(count_discontinuity(&again), 0, "a forward join is not a PCR break");
let mut counters = std::collections::HashMap::new();
for frame in before.iter().chain(&marked).chain(&after).chain(&again) {
for packet in frame.payload.as_chunks::<188>().0.iter() {
let pid = u16::from(packet[1] & 0x1f) << 8 | u16::from(packet[2]);
let cc = packet[3] & 15;
if let Some(prev) = counters.insert(pid, cc) {
let expected = (prev + u8::from(packet[3] & 0x10 != 0)) & 15;
assert_eq!(cc, expected, "counter gap on PID {pid}");
}
}
}
}
#[tokio::test(start_paused = true)]
async fn multi_packet_si_section_is_captured() {
let body: Vec<u8> = (0..400u16).map(|i| i as u8).collect();
let sdt = make_long_section(0x42, 1, 0, 0, 0, &body);
let input = si_packets_multi(0x0011, &sdt);
assert!(input.len() > 188, "the SDT must span more than one TS packet");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&input[..])).unwrap();
import.finish().unwrap();
let si = catalog.snapshot().ext.mpegts.si.clone();
let entry = si
.get(&0x0011)
.and_then(|tables| tables.get(&0x42))
.expect("an SDT entry");
assert_eq!(
read_si_sections(&consumer, &entry.track).await,
vec![Bytes::from(sdt)],
"the multi-packet SDT was reassembled and captured byte-for-byte"
);
}
struct SiRig {
import: crate::container::ts::Import<tscat::Ext>,
catalog: crate::catalog::Producer<tscat::Ext>,
consumer: moq_net::broadcast::Consumer,
}
fn si_rig() -> SiRig {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let import = crate::container::ts::Import::new(broadcast, catalog.reserve());
SiRig {
import,
catalog,
consumer,
}
}
#[tokio::test(start_paused = true)]
async fn torn_transition_is_never_published() {
let sdt = |version: u8, number: u8, fill: u8| make_long_section(0x42, 1, version, number, 1, &[fill; 4]);
let mut rig = si_rig();
let mut input = si_packet(0x0011, &sdt(5, 0, 0xaa));
input.extend_from_slice(&si_packet_cc(0x0011, &sdt(5, 1, 0xab), 1));
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import
.decode(&BytesMut::from(&si_packet_cc(0x0011, &sdt(6, 0, 0xba), 2)[..]))
.unwrap();
rig.import
.decode(&BytesMut::from(&si_packet_cc(0x0011, &sdt(6, 1, 0xbb), 3)[..]))
.unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
let track = &si[&0x0011][&0x42].track;
let groups = read_si_groups(&rig.consumer, track).await;
assert!(!groups.is_empty(), "at least one snapshot group");
for (i, frames) in groups.iter().enumerate() {
let versions: Vec<u8> = frames
.iter()
.flat_map(crate::container::ts::si::split_sections)
.map(|section| (section[5] >> 1) & 0x1f)
.collect();
assert!(
versions.windows(2).all(|w| w[0] == w[1]),
"group {i} mixes versions: {versions:?}"
);
}
assert_eq!(
read_si_sections(&rig.consumer, track).await,
vec![Bytes::from(sdt(6, 0, 0xba)), Bytes::from(sdt(6, 1, 0xbb))],
"the newest snapshot is version 6, complete"
);
}
#[tokio::test(start_paused = true)]
async fn eit_now_next_and_schedule_are_captured() {
let pf0 = make_long_section(0x4E, 1, 0, 0, 1, &[0x00, 0x01, 0x00, 0x02, 0x01, 0x4E, 0xaa]);
let pf1 = make_long_section(0x4E, 1, 0, 1, 1, &[0x00, 0x01, 0x00, 0x02, 0x01, 0x4E, 0xbb]);
let sc0 = make_long_section(0x50, 1, 0, 0, 8, &[0x00, 0x01, 0x00, 0x02, 0x08, 0x50, 0xcc]);
let sc8 = make_long_section(0x50, 1, 0, 8, 8, &[0x00, 0x01, 0x00, 0x02, 0x08, 0x50, 0xdd]);
let mut input = Vec::new();
for (cc, section) in [&pf0, &pf1, &sc0, &sc8, &sc0].into_iter().enumerate() {
input.extend_from_slice(&si_packet_cc(0x0012, section, cc as u8));
}
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
let eit = si.get(&0x0012).expect("EIT entries");
let pf = eit.get(&0x4E).expect("a now/next entry");
assert_eq!(pf.interval, Some(Duration::from_secs(2)), "now/next actual: 2s");
assert_eq!(
read_si_sections(&rig.consumer, &pf.track).await,
vec![Bytes::from(pf0), Bytes::from(pf1)],
"now/next committed on contiguity"
);
let sched = eit.get(&0x50).expect("a schedule entry");
assert_eq!(sched.interval, Some(Duration::from_secs(10)), "schedule actual: 10s");
assert_eq!(
read_si_sections(&rig.consumer, &sched.track).await,
vec![Bytes::from(sc0), Bytes::from(sc8)],
"the sparse schedule committed on cycle wrap"
);
}
fn si_rig_selecting(program: u16) -> SiRig {
let SiRig {
import,
catalog,
consumer,
} = si_rig();
SiRig {
import: import.with_program(program),
catalog,
consumer,
}
}
fn sdt_service_entry(service: u16, name: &[u8]) -> Vec<u8> {
let mut descriptor = vec![0x48, 0, 0x01, 1, b'P', name.len() as u8];
descriptor.extend_from_slice(name);
descriptor[1] = (descriptor.len() - 2) as u8;
let mut entry = service.to_be_bytes().to_vec();
entry.push(0xfd);
entry.extend_from_slice(&[0x80, descriptor.len() as u8]);
entry.extend_from_slice(&descriptor);
entry
}
fn sdt_actual(version: u8, number: u8, last: u8, services: &[(u16, &[u8])]) -> Vec<u8> {
let mut body = vec![0x00, 0x02, 0xff];
for &(service, name) in services {
body.extend(sdt_service_entry(service, name));
}
make_long_section(0x42, 1, version, number, last, &body)
}
fn eit_pf(service: u16, number: u8) -> Bytes {
let body = [0x00, 0x01, 0x00, 0x02, 0x01, 0x4E, service as u8, number];
Bytes::from(make_long_section(0x4E, service, 0, number, 1, &body))
}
fn nit() -> Bytes {
Bytes::from(make_long_section(0x40, 1, 0, 0, 0, &[0xbb; 4]))
}
fn two_service_si() -> Vec<u8> {
let mut out = si_packet(0x0011, &sdt_actual(0, 0, 1, &[(1, b"One")]));
out.extend(si_packet_cc(0x0011, &sdt_actual(0, 1, 1, &[(2, b"Two")]), 1));
for (cc, section) in [eit_pf(1, 0), eit_pf(1, 1), eit_pf(2, 0), eit_pf(2, 1)]
.iter()
.enumerate()
{
out.extend(si_packet_cc(0x0012, section, cc as u8));
}
out.extend(si_packet(0x0010, &nit()));
out
}
fn assert_sdt_lists_only(section: &[u8], version: u8, service: u16, name: &[u8]) {
let crc = crc::Crc::<u32>::new(&crc::CRC_32_MPEG_2);
assert_eq!(crc.checksum(section), 0, "a valid CRC-32/MPEG-2");
let section_length = (usize::from(section[1] & 0x0f) << 8) | usize::from(section[2]);
assert_eq!(section.len(), 3 + section_length, "section_length covers the section");
assert_eq!(section[0], 0x42, "SDT actual");
assert_eq!(
u16::from_be_bytes([section[3], section[4]]),
1,
"transport_stream_id kept"
);
assert_eq!((section[5] >> 1) & 0x1f, version, "version_number kept");
assert_eq!((section[6], section[7]), (0, 0), "section 0 of 0");
assert_eq!(
u16::from_be_bytes([section[8], section[9]]),
2,
"original_network_id kept"
);
assert_eq!(
§ion[11..section.len() - 4],
&sdt_service_entry(service, name)[..],
"the service loop holds the selected service alone"
);
}
#[tokio::test(start_paused = true)]
async fn a_selected_program_carries_only_its_own_si() {
for (program, name) in [(1u16, &b"One"[..]), (2, b"Two")] {
let mut input = crate::container::ts::import::test::two_programs();
input.extend(two_service_si());
let mut rig = si_rig_selecting(program);
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
let sdt = read_si_sections(&rig.consumer, &si[&0x0011][&0x42].track).await;
assert_eq!(sdt.len(), 1, "program {program}: one SDT actual section");
assert_sdt_lists_only(&sdt[0], 0, program, name);
assert_eq!(
read_si_sections(&rig.consumer, &si[&0x0012][&0x4E].track).await,
vec![eit_pf(program, 0), eit_pf(program, 1)],
"program {program}: only its own EIT"
);
assert_eq!(
read_si_sections(&rig.consumer, &si[&0x0010][&0x40].track).await,
vec![nit()],
"the NIT passes through"
);
}
}
#[tokio::test(start_paused = true)]
async fn an_unselected_import_keeps_every_service() {
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&two_service_si()[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
assert_eq!(
read_si_sections(&rig.consumer, &si[&0x0011][&0x42].track).await,
vec![
Bytes::from(sdt_actual(0, 0, 1, &[(1, b"One")])),
Bytes::from(sdt_actual(0, 1, 1, &[(2, b"Two")])),
],
"the SDT verbatim"
);
assert_eq!(
read_si_sections(&rig.consumer, &si[&0x0012][&0x4E].track).await,
vec![eit_pf(1, 0), eit_pf(1, 1), eit_pf(2, 0), eit_pf(2, 1)],
"every service's EIT"
);
}
#[tokio::test(start_paused = true)]
async fn a_selection_missing_from_the_sdt_carries_none() {
let mut rig = si_rig_selecting(3);
rig.import.decode(&BytesMut::from(&two_service_si()[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
assert!(si.contains_key(&0x0010), "the NIT was captured (control)");
assert!(!si.contains_key(&0x0011), "no SDT entry: {si:?}");
assert!(!si.contains_key(&0x0012), "no EIT entry: {si:?}");
}
#[tokio::test(start_paused = true)]
async fn an_sdt_revision_dropping_the_service_retires_it() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut capture = crate::container::ts::si::Capture::new(broadcast, catalog.clone());
capture.select(1);
let mut revise = |version: u8, services: &[(u16, &[u8])]| {
capture.section(0x0011, sdt_actual(version, 0, 0, services)).unwrap();
capture.flush(Timestamp::ZERO, true).unwrap();
catalog
.snapshot()
.ext
.mpegts
.si
.get(&0x0011)
.map(|tables| tables[&0x42].track.clone())
};
let track = revise(0, &[(1, b"One"), (2, b"Two")]).expect("the SDT is advertised");
assert_eq!(revise(1, &[(2, b"Two")]), None, "the revision retired the SDT entry");
assert_eq!(
revise(2, &[(1, b"One"), (2, b"Two")]),
Some(track.clone()),
"listed again, on the same track"
);
capture.finish(Timestamp::ZERO).unwrap();
let groups = read_si_groups(&consumer, &track).await;
assert_eq!(groups.len(), 3, "one snapshot per revision");
assert_sdt_lists_only(&groups[0][0], 0, 1, b"One");
assert!(groups[1].is_empty(), "the retiring snapshot carries no SDT");
assert_sdt_lists_only(&groups[2][0], 2, 1, b"One");
}
#[tokio::test(start_paused = true)]
async fn a_corrupt_sdt_keeps_the_last_good_snapshot() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut capture = crate::container::ts::si::Capture::new(broadcast, catalog.clone());
capture.select(1);
let mut feed = |section: Vec<u8>| {
capture.section(0x0011, section).unwrap();
capture.flush(Timestamp::ZERO, true).unwrap();
catalog
.snapshot()
.ext
.mpegts
.si
.get(&0x0011)
.map(|tables| tables[&0x42].track.clone())
};
let track = feed(sdt_actual(0, 0, 0, &[(1, b"One")])).expect("the SDT is advertised");
let mut corrupt = sdt_actual(1, 0, 0, &[(1, b"One")]);
let name = corrupt.len() - 4 - 3;
corrupt[name] ^= 0x01;
assert_eq!(feed(corrupt), Some(track.clone()), "the corrupt revision kept the SDT");
assert_eq!(feed(sdt_actual(2, 0, 0, &[(1, b"Uno")])), Some(track.clone()));
capture.finish(Timestamp::ZERO).unwrap();
let groups = read_si_groups(&consumer, &track).await;
assert_eq!(groups.len(), 2, "no snapshot for the corrupt revision");
assert_sdt_lists_only(&groups[0][0], 0, 1, b"One");
assert_sdt_lists_only(&groups[1][0], 2, 1, b"Uno");
}
#[tokio::test(start_paused = true)]
async fn a_partial_sdt_revision_keeps_the_last_good_snapshot() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut capture = crate::container::ts::si::Capture::new(broadcast, catalog.clone());
capture.select(1);
let mut feed = |section: Vec<u8>| {
capture.section(0x0011, section).unwrap();
capture.flush(Timestamp::ZERO, true).unwrap();
catalog
.snapshot()
.ext
.mpegts
.si
.get(&0x0011)
.map(|tables| tables[&0x42].track.clone())
};
feed(sdt_actual(0, 0, 1, &[(1, b"One")]));
let track = feed(sdt_actual(0, 1, 1, &[(2, b"Two")])).expect("the SDT is advertised");
let mut corrupt = sdt_actual(1, 0, 1, &[(1, b"One")]);
let name = corrupt.len() - 4 - 3;
corrupt[name] ^= 0x01;
assert_eq!(feed(corrupt), Some(track.clone()));
assert_eq!(feed(sdt_actual(1, 1, 1, &[(2, b"Two")])), Some(track.clone()));
assert_eq!(
feed(sdt_actual(1, 1, 1, &[(2, b"Two")])),
Some(track.clone()),
"the repeated section did not commit the partial revision"
);
assert_eq!(feed(sdt_actual(1, 0, 1, &[(1, b"Uno")])), Some(track.clone()));
capture.finish(Timestamp::ZERO).unwrap();
let groups = read_si_groups(&consumer, &track).await;
assert_eq!(groups.len(), 2, "no snapshot for the partial revision");
assert_sdt_lists_only(&groups[0][0], 0, 1, b"One");
assert_sdt_lists_only(&groups[1][0], 1, 1, b"Uno");
}
#[tokio::test(start_paused = true)]
async fn a_selection_filters_eit_schedule() {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut capture = crate::container::ts::si::Capture::new(broadcast, catalog.clone());
capture.select(1);
let schedule = |service: u16| {
let body = [0x00, 0x01, 0x00, 0x02, 0x00, 0x50, service as u8];
make_long_section(0x50, service, 0, 0, 0, &body)
};
for service in [1, 2] {
capture.section(0x0012, schedule(service)).unwrap();
}
capture.finish(Timestamp::ZERO).unwrap();
let si = catalog.snapshot().ext.mpegts.si.clone();
assert_eq!(
read_si_sections(&consumer, &si[&0x0012][&0x50].track).await,
vec![Bytes::from(schedule(1))],
"only the selected service's schedule"
);
}
#[tokio::test(start_paused = true)]
async fn a_selected_programs_si_survives_export() {
let mut input = crate::container::ts::import::test::two_programs();
input.extend(two_service_si());
let mut rig = si_rig_selecting(2);
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let ts = drain_with(
Export::with_ts(
crate::source::announced(&rig.consumer),
crate::catalog::CatalogFormat::Hang,
)
.await
.unwrap(),
)
.await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
while reader.read_ts_packet().unwrap().is_some() {}
let mut again = si_rig();
again.import.decode(&BytesMut::from(ts.as_ref())).unwrap();
again.import.finish().unwrap();
let si = again.catalog.snapshot().ext.mpegts.si.clone();
let sdt = read_si_sections(&again.consumer, &si[&0x0011][&0x42].track).await;
assert_eq!(sdt.len(), 1, "one SDT actual section");
assert_sdt_lists_only(&sdt[0], 0, 2, b"Two");
assert_eq!(
read_si_sections(&again.consumer, &si[&0x0012][&0x4E].track).await,
vec![eit_pf(2, 0), eit_pf(2, 1)],
"only program 2's EIT"
);
}
#[tokio::test(start_paused = true)]
async fn sdt_other_networks_do_not_collide() {
let net1 = make_long_section(0x46, 7, 0, 0, 0, &[0x00, 0x01, 0xaa]);
let net2 = make_long_section(0x46, 7, 0, 0, 0, &[0x00, 0x02, 0xbb]);
let net1v2 = make_long_section(0x46, 7, 1, 0, 0, &[0x00, 0x01, 0xcc]);
let mut input = si_packet(0x0011, &net1);
input.extend_from_slice(&si_packet_cc(0x0011, &net2, 1));
input.extend_from_slice(&si_packet_cc(0x0011, &net1v2, 2));
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
let entry = &si[&0x0011][&0x46];
assert_eq!(
read_si_sections(&rig.consumer, &entry.track).await,
vec![Bytes::from(net1v2), Bytes::from(net2)],
"both networks survive; the revision replaced only its own network"
);
}
#[tokio::test(start_paused = true)]
async fn next_version_sections_are_dropped() {
let mut next = make_long_section(0x42, 1, 3, 0, 0, &[0xaa; 4]);
next[5] &= !0x01;
let nit = make_long_section(0x40, 1, 0, 0, 0, &[0xbb; 4]);
let mut input = si_packet(0x0011, &next);
input.extend_from_slice(&si_packet_cc(0x0011, &next, 1));
input.extend_from_slice(&si_packet(0x0010, &nit));
input.extend_from_slice(&si_packet_cc(0x0010, &nit, 1));
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
assert!(si.contains_key(&0x0010), "the current NIT was captured (control)");
assert!(!si.contains_key(&0x0011), "a next-version section creates no entry");
}
#[tokio::test(start_paused = true)]
async fn tdt_round_trips_as_latest_value() {
let tick = |mjd: u8| make_short_section(0x70, &[0xc0, mjd, 0x12, 0x34, 0x56]);
let sdt = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 4]);
let mut rig = si_rig();
let mut input = si_packet(0x0014, &tick(1));
input.extend_from_slice(&si_packet_cc(0x0014, &tick(1), 1));
input.extend_from_slice(&si_packet(0x0011, &sdt));
input.extend_from_slice(&si_packet_cc(0x0011, &sdt, 1));
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import
.decode(&BytesMut::from(&si_packet_cc(0x0014, &tick(2), 2)[..]))
.unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
assert!(si.contains_key(&0x0011), "the SDT was captured (control)");
let entry = si
.get(&0x0014)
.and_then(|tables| tables.get(&0x70))
.expect("a TDT entry");
assert_eq!(entry.interval, Some(Duration::from_secs(30)), "the TDT/TOT 30s maximum");
assert_eq!(
read_si_sections(&rig.consumer, &entry.track).await,
vec![Bytes::from(tick(2))],
"the newest snapshot is the latest tick alone"
);
let groups = read_si_groups(&rig.consumer, &entry.track).await;
assert_eq!(groups.len(), 2, "one group per tick, no accumulation");
}
fn make_short_section(table_id: u8, body: &[u8]) -> Vec<u8> {
let mut s = vec![
table_id,
0x30 | ((body.len() >> 8) as u8 & 0x0f),
(body.len() & 0xff) as u8,
];
s.extend_from_slice(body);
s
}
#[tokio::test(start_paused = true)]
async fn abort_removes_si_catalog_entries() {
let sdt = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 4]);
let mut input = si_packet(0x0011, &sdt);
input.extend_from_slice(&si_packet_cc(0x0011, &sdt, 1));
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
assert!(
!rig.catalog.snapshot().ext.mpegts.si.is_empty(),
"the SDT entry was advertised"
);
rig.import.abort(moq_net::Error::Cancel);
assert!(
rig.catalog.snapshot().ext.mpegts.si.is_empty(),
"abort removed the advertised entries"
);
}
#[tokio::test(start_paused = true)]
async fn short_form_repetition_cuts_no_group() {
let tdt = make_short_section(0x70, &[0xc0, 0x79, 0x12, 0x34, 0x56]);
let mut rig = si_rig();
let mut input = si_packet(0x0014, &tdt);
input.extend_from_slice(&si_packet_cc(0x0014, &tdt, 1));
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import
.decode(&BytesMut::from(&si_packet_cc(0x0014, &tdt, 2)[..]))
.unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
let groups = read_si_groups(&rig.consumer, &si[&0x0014][&0x70].track).await;
assert_eq!(groups.len(), 1, "a repetition cut no further group");
}
#[tokio::test(start_paused = true)]
async fn stale_si_entry_does_not_block_output() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let ghost = broadcast.create_track("ghost.si", None).unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name.clone(), cfg);
guard.ext.mpegts.si.entry(0x0011).or_default().insert(
0x42,
tscat::SiEntry {
track: "ghost.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 64));
producer
.write(Frame {
timestamp: Timestamp::ZERO,
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
producer.finish().unwrap();
let mut exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap();
let frame = tokio::time::timeout(Duration::from_secs(1), exporter.next())
.await
.expect("a stale SI entry must not hold output dark")
.unwrap()
.expect("a muxed frame");
assert!(!frame.payload.is_empty());
assert!(
!frame
.payload
.as_chunks::<188>()
.0
.iter()
.any(|p| ((((p[1] & 0x1f) as u16) << 8) | p[2] as u16) == 0x0011),
"nothing was emitted for the undelivered entry"
);
drop(ghost);
}
fn opus_packet(fill: u8, len: usize) -> Bytes {
let mut p = vec![(1 << 3) | (1 << 2)]; p.extend(std::iter::repeat_n(fill, len));
Bytes::from(p)
}
fn strip_opus_control(mut data: &[u8]) -> Vec<Vec<u8>> {
let mut packets = Vec::new();
while !data.is_empty() {
assert_eq!(data[0], 0x7f, "control header sync byte 0");
assert_eq!(data[1] & 0xe0, 0xe0, "control header sync byte 1");
assert_eq!(data[1] & 0x1c, 0x00, "exporter emits no trim / extension flags");
let mut pos = 2;
let mut size = 0usize;
loop {
let b = data[pos];
pos += 1;
size += b as usize;
if b != 0xff {
break;
}
}
packets.push(data[pos..pos + size].to_vec());
data = &data[pos + size..];
}
packets
}
#[tokio::test(start_paused = true)]
async fn export_opus_roundtrip() {
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 track = broadcast
.create_track(
broadcast.unique_name(".opus"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
cfg.container = Container::Legacy;
catalog.modify().unwrap().audio.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let packets: Vec<Bytes> = vec![opus_packet(0x01, 4), opus_packet(0x10, 8), opus_packet(0x20, 200)];
for (i, payload) in packets.iter().enumerate() {
producer
.write(Frame {
timestamp: Timestamp::from_micros(i as u64 * 20_000).unwrap(),
duration: None,
payload: payload.clone(),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
}
producer.finish().unwrap();
let ts = drain(consumer).await;
assert_packet_aligned(&ts);
let mut reader = TsPacketReader::new(Cursor::new(ts.as_ref()));
let mut saw_pmt = false;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::Pmt(pmt)) = packet.payload {
saw_pmt = true;
assert_eq!(pmt.es_info.len(), 1);
let es = &pmt.es_info[0];
assert_eq!(es.stream_type, StreamType::from_u8(0x06).unwrap());
let reg = es.descriptors.iter().find(|d| d.tag == 0x05).expect("registration");
assert_eq!(reg.data, b"Opus");
let ext = es.descriptors.iter().find(|d| d.tag == 0x7f).expect("extension");
assert_eq!(ext.data, vec![0x80, 0x02], "ext tag 0x80 + stereo channel_config_code");
}
}
assert!(saw_pmt, "missing PMT");
let mut pes = PesPacketReader::new(TsPacketReader::new(Cursor::new(ts.as_ref())));
let mut recovered: Vec<(u64, Vec<u8>)> = Vec::new();
while let Some(packet) = pes.read_pes_packet().unwrap() {
assert_eq!(
packet.header.stream_id.as_u8(),
mpeg2ts::es::StreamId::PRIVATE_STREAM_1,
"Opus rides private_stream_1"
);
let pts = packet.header.pts.expect("PES carried no PTS").as_u64();
for raw in strip_opus_control(&packet.data) {
recovered.push((pts, raw));
}
}
assert_eq!(recovered.len(), packets.len());
for (i, payload) in packets.iter().enumerate() {
let (pts, raw) = &recovered[i];
assert_eq!(*pts, i as u64 * 20 * 90, "PTS should be ms * 90 (90 kHz)");
assert_eq!(raw.as_slice(), payload.as_ref(), "raw Opus payload mismatch");
}
}
#[tokio::test(start_paused = true)]
async fn opus_export_import_roundtrip() {
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 track = broadcast
.create_track(
broadcast.unique_name(".opus"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = AudioConfig::new(AudioCodec::Opus, 48_000, 2);
cfg.container = Container::Legacy;
catalog.modify().unwrap().audio.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let packets: Vec<Bytes> = (0..4).map(|i| opus_packet(0x40 + i as u8, 24)).collect();
for (i, payload) in packets.iter().enumerate() {
producer
.write(Frame {
timestamp: Timestamp::from_micros(i as u64 * 20_000).unwrap(),
duration: None,
payload: payload.clone(),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
}
producer.finish().unwrap();
let ts = drain(consumer).await;
let mut imported = moq_net::broadcast::Info::new().produce();
let imported_consumer = imported.consume();
let import_catalog = crate::catalog::Producer::new(&mut imported, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(imported, import_catalog.reserve());
import.decode(&ts).unwrap();
import.finish().unwrap();
let snapshot = import_catalog.snapshot();
assert_eq!(snapshot.audio.renditions.len(), 1, "expected one Opus track");
let (opus_name, audio) = snapshot.audio.renditions.iter().next().unwrap();
assert_eq!(audio.codec.to_string(), "opus");
assert_eq!(audio.sample_rate, 48_000);
assert_eq!(audio.channel_count, 2);
let recovered = read_frames(&imported_consumer, opus_name, Kind::Audio).await;
assert_eq!(recovered.len(), packets.len(), "frame count");
for (orig, got) in packets.iter().zip(&recovered) {
assert_eq!(got.as_slice(), orig.as_ref(), "Opus packet survived the round-trip");
}
}
const VIDEO_US: u64 = 40_000;
const AUDIO_US: u64 = 21_333;
const GOP: u64 = 15;
const AUDIO_GROUP: u64 = 28;
const TICKS: u64 = 150;
const JOIN: u64 = 75;
async fn export_twice(with_video: bool) -> (Vec<Frame>, Vec<Frame>) {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let _sdt = publish_sdt(&mut broadcast, &mut catalog);
let mut video = with_video.then(|| {
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description =
Some(crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap());
catalog
.modify()
.unwrap()
.video
.renditions
.insert(track.name().to_string(), cfg);
Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data))
});
let mut audio = {
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.audio
.renditions
.insert(track.name().to_string(), cfg);
Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data))
};
let source = crate::source::announced(&consumer);
let export = || Export::with_ts(source.clone(), crate::catalog::CatalogFormat::Hang);
let mut a = export().await.unwrap();
let mut b = None;
let (mut out_a, mut out_b) = (Vec::new(), Vec::new());
let mut audio_index = 0;
for tick in 0..TICKS {
if let Some(video) = video.as_mut() {
let keyframe = tick % GOP == 0;
let slice = if keyframe {
vec![0x65u8; 3_000]
} else {
vec![0x41u8; 400]
};
video
.write(Frame {
timestamp: Timestamp::from_micros(tick * VIDEO_US).unwrap(),
duration: None,
payload: length_prefixed(&[&slice]),
keyframe,
})
.unwrap();
}
while audio_index * AUDIO_US < (tick + 1) * VIDEO_US {
audio
.write(Frame {
timestamp: Timestamp::from_micros(audio_index * AUDIO_US).unwrap(),
duration: None,
payload: Bytes::from_iter((0..180u16).map(|i| (i ^ audio_index as u16) as u8)),
keyframe: audio_index % AUDIO_GROUP == 0,
})
.unwrap();
audio_index += 1;
}
out_a.extend(drain_frames(&mut a).await);
if let Some(b) = b.as_mut() {
out_b.extend(drain_frames(b).await);
}
if tick + 1 == JOIN {
b = Some(export().await.unwrap());
}
}
(out_a, out_b)
}
async fn drain_frames<E: tscat::Catalog>(export: &mut Export<E>) -> Vec<Frame> {
let mut out = Vec::new();
while let Ok(res) = tokio::time::timeout(Duration::from_secs(1), export.next()).await {
match res.expect("exporter error") {
Some(frame) => out.push(frame),
None => break,
}
}
out
}
fn assert_only_continuity_differs(a: &[Frame], b: &[Frame], from: Timestamp) {
let a: Vec<&Frame> = a.iter().filter(|f| f.timestamp >= from).collect();
let b: Vec<&Frame> = b.iter().filter(|f| f.timestamp >= from).collect();
assert!(b.len() > 20, "not enough overlap to be worth comparing: {}", b.len());
assert_eq!(a.len(), b.len(), "exporters rendered a different number of frames");
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(a.timestamp, b.timestamp, "compared frames must be the same frame");
assert_eq!(
a.payload.len(),
b.payload.len(),
"same frame rendered to a different size"
);
assert_packet_aligned(&a.payload);
for (offset, (x, y)) in a.payload.iter().zip(b.payload.iter()).enumerate() {
if x == y {
continue;
}
assert_eq!(
(offset % 188, (x ^ y) & 0xf0),
(3, 0),
"frame at {:?} differs outside the continuity counter: offset {offset}, {x:#04x} vs {y:#04x}",
a.timestamp,
);
}
}
}
#[tokio::test(start_paused = true)]
async fn late_join_matches_a_running_exporter() {
let (a, b) = export_twice(true).await;
let keyframes: Vec<Timestamp> = b.iter().filter(|f| f.keyframe).map(|f| f.timestamp).collect();
assert_only_continuity_differs(&a, &b, keyframes[1]);
}
#[tokio::test(start_paused = true)]
async fn late_join_matches_a_running_exporter_without_video() {
let (a, b) = export_twice(false).await;
let from = b
.iter()
.filter(|frame| count_pid(std::slice::from_ref(*frame), 0) > 0)
.nth(1)
.expect("a periodic PAT/PMT refresh")
.timestamp;
assert_only_continuity_differs(&a, &b, from);
}
struct Interleave {
_broadcast: moq_net::broadcast::Producer,
_catalog: crate::catalog::Producer,
video: Producer<HangContainer>,
audio: Producer<HangContainer>,
source: crate::Source,
audio_index: u64,
}
impl Interleave {
fn new() -> Self {
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 track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description =
Some(crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap());
catalog
.modify()
.unwrap()
.video
.renditions
.insert(track.name().to_string(), cfg);
let video = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Video));
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.audio
.renditions
.insert(track.name().to_string(), cfg);
let audio = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Audio));
Self {
source: crate::source::announced(&consumer),
_broadcast: broadcast,
_catalog: catalog,
video,
audio,
audio_index: 0,
}
}
async fn export(&self, max_age: Duration) -> Export {
let mut export = Export::new(self.source.clone()).await.unwrap().with_max_age(max_age);
assert!(drain_frames(&mut export).await.is_empty());
export
}
fn video(&mut self, tick: u64) {
let keyframe = tick.is_multiple_of(GOP);
let slice = if keyframe {
vec![0x65u8; 3_000]
} else {
vec![0x41u8; 400]
};
self.video
.write(Frame {
timestamp: Timestamp::from_micros(tick * VIDEO_US).unwrap(),
duration: None,
payload: length_prefixed(&[&slice]),
keyframe,
})
.unwrap();
}
fn audio_until(&mut self, until: u64, export: &mut Export, out: &mut Vec<Frame>) {
while self.audio_index * AUDIO_US < until {
let index = self.audio_index;
self.audio
.write(Frame {
timestamp: Timestamp::from_micros(index * AUDIO_US).unwrap(),
duration: None,
payload: Bytes::from_iter((0..180u16).map(|i| (i ^ index as u16) as u8)),
keyframe: index.is_multiple_of(AUDIO_GROUP),
})
.unwrap();
self.audio_index += 1;
out.extend(poll_frames(export));
}
}
fn finish(&mut self) {
self.video.finish().unwrap();
self.audio.finish().unwrap();
}
}
fn poll_frames<E: tscat::Catalog>(export: &mut Export<E>) -> Vec<Frame> {
let waiter = kio::Waiter::noop();
let mut out = Vec::new();
while let std::task::Poll::Ready(frame) = export.poll_next(&waiter) {
match frame.expect("exporter error") {
Some(frame) => out.push(frame),
None => break,
}
}
out
}
fn pes_pts_in_order(frames: &[Frame]) -> Vec<u64> {
let bytes: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
let mut reader = TsPacketReader::new(Cursor::new(bytes));
let mut out = Vec::new();
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(TsPayload::PesStart(pes)) = packet.payload
&& let Some(pts) = pes.header.pts
{
out.push(pts.as_u64());
}
}
out
}
async fn lagging_audio(max_age: Duration) -> Vec<u64> {
let mut rig = Interleave::new();
let mut export = rig.export(max_age).await;
let mut out = Vec::new();
for tick in 0..TICKS / 2 {
rig.video(tick);
out.extend(poll_frames(&mut export));
rig.audio_until(tick * VIDEO_US, &mut export, &mut out);
}
rig.finish();
out.extend(drain_frames(&mut export).await);
pes_pts_in_order(&out)
}
#[tokio::test(start_paused = true)]
async fn late_audio_still_leads_the_interleave() {
let pts = lagging_audio(Duration::from_millis(500)).await;
assert!(pts.len() > 100, "too little output to judge: {}", pts.len());
assert!(pts.is_sorted(), "a frame went out ahead of an earlier one: {pts:?}");
}
#[tokio::test(start_paused = true)]
async fn zero_max_age_keeps_arrival_order() {
let pts = lagging_audio(Duration::ZERO).await;
assert!(
!pts.is_sorted(),
"video should have led the audio that arrived after it"
);
}
#[tokio::test(start_paused = true)]
async fn arrival_order_does_not_change_the_output() {
let mut rig = Interleave::new();
let max_age = Duration::from_millis(500);
let (mut eager, mut batched) = (rig.export(max_age).await, rig.export(max_age).await);
let (mut out_eager, mut out_batched) = (Vec::new(), Vec::new());
for tick in 0..TICKS / 2 {
rig.video(tick);
out_eager.extend(poll_frames(&mut eager));
rig.audio_until(tick * VIDEO_US, &mut eager, &mut out_eager);
out_batched.extend(poll_frames(&mut batched));
}
rig.finish();
out_eager.extend(drain_frames(&mut eager).await);
out_batched.extend(drain_frames(&mut batched).await);
let eager: Vec<u8> = out_eager.iter().flat_map(|f| f.payload.iter().copied()).collect();
let batched: Vec<u8> = out_batched.iter().flat_map(|f| f.payload.iter().copied()).collect();
assert!(eager.len() > 100 * 188, "too little output to compare: {}", eager.len());
assert!(eager == batched, "arrival order changed the rendering");
}
#[tokio::test(start_paused = true)]
async fn quiet_track_is_emitted_around_then_rejoins() {
let max_age = Duration::from_millis(500);
let mut rig = Interleave::new();
let mut export = rig.export(max_age).await;
let mut out = Vec::new();
rig.video(0);
rig.audio_until(1, &mut export, &mut out);
for tick in 1..=10 {
rig.video(tick);
}
assert!(poll_frames(&mut export).is_empty(), "video waits for the quiet audio");
tokio::time::advance(max_age).await;
out.extend(poll_frames(&mut export));
assert!(
pes_pts_in_order(&out)
.iter()
.any(|&pts| pts >= 9 * VIDEO_US * 90 / 1_000),
"video went out around the quiet audio"
);
rig.audio_until(10 * VIDEO_US, &mut export, &mut out);
for tick in 11..TICKS / 2 {
rig.video(tick);
out.extend(poll_frames(&mut export));
rig.audio_until(tick * VIDEO_US, &mut export, &mut out);
}
rig.finish();
out.extend(drain_frames(&mut export).await);
let mut pts = pes_pts_in_order(&out);
let resumed = pts.iter().position(|&pts| pts >= 11 * VIDEO_US * 90 / 1_000).unwrap();
let pts = pts.split_off(resumed);
assert!(pts.len() > 100, "too little output to judge: {}", pts.len());
assert!(pts.is_sorted(), "the interleave did not resume: {pts:?}");
}
#[tokio::test(start_paused = true)]
async fn tune_in_does_not_wait_on_dropped_audio() {
let mut rig = Interleave::new();
let mut export = rig.export(Duration::from_millis(500)).await;
let mut out = Vec::new();
rig.audio_until(3 * GOP * VIDEO_US, &mut export, &mut out);
for tick in GOP..2 * GOP {
rig.video(tick);
}
out.extend(poll_frames(&mut export));
assert!(!out.is_empty(), "the tune-in waited on audio it had dropped");
}
#[tokio::test(start_paused = true)]
async fn rewind_keeps_an_expired_stall() {
let max_age = Duration::from_millis(500);
let mut rig = Interleave::new();
let mut export = rig.export(max_age).await;
let mut out = Vec::new();
rig.video(0);
rig.audio_until(1, &mut export, &mut out);
for tick in 1..=5 {
rig.video(tick);
}
assert!(poll_frames(&mut export).is_empty(), "video waits for the quiet audio");
tokio::time::advance(max_age).await;
out.extend(poll_frames(&mut export));
assert!(!out.is_empty(), "video went out around the quiet audio");
rig.video.discontinuity().unwrap();
for tick in 0..=5 {
rig.video(GOP + tick);
}
out.extend(poll_frames(&mut export));
let after = pes_pts_in_order(&out)
.into_iter()
.filter(|&pts| pts >= GOP * VIDEO_US * 90 / 1_000)
.count();
assert!(after > 0, "the new generation waited on the audio again");
assert_eq!(export.discontinuity(), 1);
}
#[tokio::test(start_paused = true)]
async fn rewind_keeps_a_held_frame_waiting_time() {
let max_age = Duration::from_millis(500);
let mut rig = Interleave::new();
let mut export = rig.export(max_age).await;
let mut out = Vec::new();
rig.video(0);
rig.audio_until(1, &mut export, &mut out);
for tick in 1..=5 {
rig.video(tick);
}
rig.audio_index = (GOP + 3) * VIDEO_US / AUDIO_US;
rig.audio_until(rig.audio_index * AUDIO_US + 1, &mut export, &mut out);
tokio::time::advance(max_age).await;
rig.video.discontinuity().unwrap();
rig.video(GOP);
out.extend(poll_frames(&mut export));
assert_eq!(export.discontinuity(), 1, "the held audio waited a second budget");
}
#[tokio::test(start_paused = true)]
async fn rewind_keeps_a_partial_stall() {
let max_age = Duration::from_millis(500);
let mut rig = Interleave::new();
let mut export = rig.export(max_age).await;
let mut out = Vec::new();
rig.video(0);
rig.audio_until(1, &mut export, &mut out);
for tick in 1..=5 {
rig.video(tick);
}
assert!(poll_frames(&mut export).is_empty(), "video waits for the quiet audio");
tokio::time::advance(max_age / 2).await;
assert!(poll_frames(&mut export).is_empty(), "the hold lapsed early");
rig.video.discontinuity().unwrap();
for tick in 0..=5 {
rig.video(GOP + tick);
}
assert!(poll_frames(&mut export).is_empty(), "the rewind ended the hold");
tokio::time::advance(max_age / 2).await;
assert!(!poll_frames(&mut export).is_empty(), "the rewind renewed the hold");
assert_eq!(export.discontinuity(), 1);
}
#[tokio::test(start_paused = true)]
async fn repeated_rewinds_hold_once() {
let max_age = Duration::from_millis(500);
let mut rig = Interleave::new();
let mut export = rig.export(max_age).await;
let mut out = Vec::new();
rig.video(0);
rig.audio_until(1, &mut export, &mut out);
for tick in 1..=5 {
rig.video(tick);
}
assert!(poll_frames(&mut export).is_empty(), "video waits for the quiet audio");
tokio::time::advance(max_age).await;
out.extend(poll_frames(&mut export));
for generation in 1..=8 {
rig.video.discontinuity().unwrap();
for tick in 0..=5 {
rig.video(generation * GOP + tick);
}
out.extend(poll_frames(&mut export));
assert_eq!(
export.discontinuity(),
generation,
"rewind {generation} held the feed for a fresh budget"
);
}
}
#[tokio::test(start_paused = true)]
async fn lost_dense_section_recovers_on_the_next_cycle() {
let sdt = |number: u8, fill: u8| make_long_section(0x42, 1, 0, number, 2, &[fill; 4]);
let s0 = sdt(0, 0xa0);
let s1 = sdt(1, 0xa1);
let s2 = sdt(2, 0xa2);
let mut input = si_packet(0x0011, &s0);
input.extend_from_slice(&si_packet_cc(0x0011, &s2, 1));
input.extend_from_slice(&si_packet_cc(0x0011, &s0, 2));
input.extend_from_slice(&si_packet_cc(0x0011, &s1, 3));
input.extend_from_slice(&si_packet_cc(0x0011, &s1, 4));
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
assert_eq!(
read_si_sections(&rig.consumer, &si[&0x0011][&0x42].track).await,
vec![Bytes::from(s0), Bytes::from(s1), Bytes::from(s2)],
"the lost section joined the committed generation instead of replacing it"
);
}
#[tokio::test(start_paused = true)]
async fn repointed_si_entry_resubscribes() {
let sdt_a = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8]);
let sdt_b = make_long_section(0x42, 1, 1, 0, 0, &[0xbb; 8]);
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut track_a = broadcast.create_track("a.si", None).unwrap();
track_a
.write_frame(Timestamp::ZERO, Bytes::from(sdt_a.clone()))
.unwrap();
let mut track_b = broadcast.create_track("b.si", None).unwrap();
track_b
.write_frame(Timestamp::ZERO, Bytes::from(sdt_b.clone()))
.unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name.clone(), cfg);
guard.ext.mpegts.si.entry(0x0011).or_default().insert(
0x42,
tscat::SiEntry {
track: "a.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 64));
let write_key = |producer: &mut Producer<HangContainer>, sec: u64| {
producer
.write(Frame {
timestamp: Timestamp::from_micros(sec * 1_000_000).unwrap(),
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
};
let mut exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
let mut before = BytesMut::new();
write_key(&mut producer, 0);
write_key(&mut producer, 1);
for frame in drain_frames(&mut exporter).await {
before.extend_from_slice(&frame.payload);
}
catalog
.modify()
.unwrap()
.ext
.mpegts
.si
.get_mut(&0x0011)
.unwrap()
.insert(
0x42,
tscat::SiEntry {
track: "b.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
let mut after = BytesMut::new();
write_key(&mut producer, 2);
let frame = tokio::time::timeout(Duration::from_secs(1), exporter.next())
.await
.expect("a frame after the switch")
.unwrap()
.unwrap();
after.extend_from_slice(&frame.payload);
for _ in 0..100 {
tokio::task::yield_now().await;
}
write_key(&mut producer, 3);
write_key(&mut producer, 4);
producer.finish().unwrap();
while let Ok(res) = tokio::time::timeout(Duration::from_secs(1), exporter.next()).await {
let Some(frame) = res.unwrap() else { break };
after.extend_from_slice(&frame.payload);
}
let contains = |haystack: &[u8], needle: &[u8]| haystack.windows(needle.len()).any(|w| w == needle);
assert!(
contains(&before, &sdt_a),
"the original track's SDT was emitted (control)"
);
assert!(
contains(&after, &sdt_b),
"the replacement track's SDT is emitted after the repoint"
);
}
#[tokio::test(start_paused = true)]
async fn si_revision_after_final_media_frame_is_flushed() {
let sdt_v1 = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8]);
let sdt_v2 = make_long_section(0x42, 1, 1, 0, 0, &[0xbb; 8]);
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut si_track = broadcast.create_track("0x0011-0x42.si", None).unwrap();
si_track
.write_frame(Timestamp::ZERO, Bytes::from(sdt_v1.clone()))
.unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name.clone(), cfg);
guard.ext.mpegts.si.entry(0x0011).or_default().insert(
0x42,
tscat::SiEntry {
track: "0x0011-0x42.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut idr = vec![0x65u8];
idr.extend(std::iter::repeat_n(0xAB, 64));
producer
.write(Frame {
timestamp: Timestamp::ZERO,
duration: None,
payload: length_prefixed(&[&idr]),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
producer.finish().unwrap();
let mut exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap();
let first = loop {
let frame = tokio::time::timeout(Duration::from_secs(1), exporter.next())
.await
.expect("the only media frame")
.unwrap()
.unwrap();
if !is_pcr_frame(&frame) {
break frame;
}
};
let contains = |haystack: &[u8], needle: &[u8]| haystack.windows(needle.len()).any(|w| w == needle);
assert!(contains(&first.payload, &sdt_v1), "v1 rode the media frame (control)");
si_track
.write_frame(Timestamp::ZERO, Bytes::from(sdt_v2.clone()))
.unwrap();
catalog.finish().unwrap();
let tail = tokio::time::timeout(Duration::from_secs(1), exporter.next())
.await
.expect("a trailing SI frame rather than an immediate end")
.unwrap()
.expect("the trailing SI frame");
assert_packet_aligned(&tail.payload);
assert!(
contains(&tail.payload, &sdt_v2),
"the trailing flush carries the revision"
);
let end = tokio::time::timeout(Duration::from_secs(1), exporter.next())
.await
.expect("the stream ends after the flush")
.unwrap();
assert!(end.is_none(), "end of stream after the trailing flush");
}
struct SiCadenceRig {
started: bool,
producer: Producer<HangContainer>,
si_track: moq_net::track::Producer,
exporter: Export<tscat::Ext>,
catalog: crate::catalog::Producer<tscat::Ext>,
}
async fn si_cadence_rig(pid: u16, table_id: u8, interval: Duration) -> SiCadenceRig {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let si_name = format!("{pid:#06x}-{table_id:#04x}.si");
let si_track = broadcast.create_track(si_name.as_str(), None).unwrap();
let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name, cfg);
guard.ext.mpegts.si.entry(pid).or_default().insert(
table_id,
tscat::SiEntry {
track: si_name,
interval: Some(interval),
..Default::default()
},
);
}
let producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let exporter = Export::with_ts(crate::source::announced(&consumer), crate::catalog::CatalogFormat::Hang)
.await
.unwrap();
SiCadenceRig {
started: false,
producer,
si_track,
exporter,
catalog,
}
}
impl SiCadenceRig {
async fn media_frames(&mut self, millis: u64) -> Vec<Frame> {
let mut nal = vec![if self.started { 0x41u8 } else { 0x65u8 }];
nal.extend(std::iter::repeat_n(0xAB, 64));
self.producer
.write(Frame {
timestamp: Timestamp::from_millis(millis).unwrap(),
duration: None,
payload: length_prefixed(&[&nal]),
keyframe: !self.started,
})
.unwrap();
self.started = true;
let frames = drain_frames(&mut self.exporter).await;
for frame in &frames {
assert_packet_aligned(&frame.payload);
}
frames
}
async fn media(&mut self, millis: u64, pid: u16) -> usize {
count_pid(&self.media_frames(millis).await, pid)
}
}
#[tokio::test(start_paused = true)]
async fn si_revision_does_not_wait_for_the_interval() {
let tick = |mjd: u8| make_short_section(0x70, &[0xc0, mjd, 0x12, 0x34, 0x56]);
let mut rig = si_cadence_rig(0x0014, 0x70, Duration::from_secs(30)).await;
rig.si_track.write_frame(Timestamp::ZERO, Bytes::from(tick(1))).unwrap();
assert_eq!(rig.media(0, 0x0014).await, 0, "the first span stays buffered");
assert_eq!(
rig.media(1_000, 0x0014).await,
1,
"the next span closes the lead emission"
);
assert_eq!(rig.media(2_000, 0x0014).await, 0, "unchanged: still held");
rig.si_track.write_frame(Timestamp::ZERO, Bytes::from(tick(2))).unwrap();
assert_eq!(rig.media(3_000, 0x0014).await, 0, "the revision enters the open span");
assert_eq!(rig.media(4_000, 0x0014).await, 1, "the next span closes the revision");
}
#[tokio::test(start_paused = true)]
async fn si_repeats_ride_the_media_grid() {
let sdt_v1 = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8]);
let sdt_v2 = make_long_section(0x42, 1, 1, 0, 0, &[0xbb; 8]);
let mut rig = si_cadence_rig(0x0011, 0x42, Duration::from_secs(2)).await;
rig.si_track.write_frame(Timestamp::ZERO, Bytes::from(sdt_v1)).unwrap();
assert_eq!(rig.media(1_500, 0x0011).await, 0, "the first span stays buffered");
assert_eq!(
rig.media(1_900, 0x0011).await,
1,
"the next span closes the lead emission"
);
assert_eq!(rig.media(2_000, 0x0011).await, 0, "the repeat enters the open span");
assert_eq!(rig.media(2_500, 0x0011).await, 1, "the next span closes the repeat");
rig.si_track.write_frame(Timestamp::ZERO, Bytes::from(sdt_v2)).unwrap();
assert_eq!(rig.media(2_900, 0x0011).await, 0, "within the floor of the repeat");
assert_eq!(
rig.media(3_000, 0x0011).await,
0,
"the revision enters the span once its floor lapses"
);
assert_eq!(rig.media(3_500, 0x0011).await, 1, "the next span closes the revision");
assert_eq!(rig.media(4_000, 0x0011).await, 0, "the 4s repeat enters the open span");
assert_eq!(rig.media(5_000, 0x0011).await, 1, "the next span closes it");
assert_eq!(rig.media(5_900, 0x0011).await, 0, "nothing between grid slots");
assert_eq!(rig.media(6_000, 0x0011).await, 0, "the 6s repeat enters the open span");
assert_eq!(rig.media(6_100, 0x0011).await, 1, "the next span closes it");
}
#[tokio::test(start_paused = true)]
async fn si_clock_tables_do_not_repeat_unchanged() {
let tick = |mjd: u8| make_short_section(0x70, &[0xc0, mjd, 0x12, 0x34, 0x56]);
let mut rig = si_cadence_rig(0x0014, 0x70, Duration::from_secs(2)).await;
rig.si_track.write_frame(Timestamp::ZERO, Bytes::from(tick(1))).unwrap();
assert_eq!(rig.media(0, 0x0014).await, 0, "the first span stays buffered");
assert_eq!(
rig.media(1_000, 0x0014).await,
1,
"the next span closes the lead emission"
);
let mut repeats = 0;
for millis in (2_000..=9_000).step_by(1_000) {
repeats += rig.media(millis, 0x0014).await;
}
assert_eq!(repeats, 0, "an unchanged time is never re-sent");
rig.si_track.write_frame(Timestamp::ZERO, Bytes::from(tick(2))).unwrap();
assert_eq!(rig.media(10_000, 0x0014).await, 0, "the new time enters the open span");
assert_eq!(rig.media(11_000, 0x0014).await, 1, "the next span closes it");
}
#[tokio::test(start_paused = true)]
async fn si_reordered_frames_earn_no_emission_credit() {
let section = |version: u8| make_long_section(0x42, 1, version, 0, 0, &[version; 8]);
let mut rig = si_cadence_rig(0x0011, 0x42, Duration::from_secs(10)).await;
rig.si_track
.write_frame(Timestamp::ZERO, Bytes::from(section(0)))
.unwrap();
assert_eq!(rig.media(1_000, 0x0011).await, 0, "the first span stays buffered");
rig.si_track
.write_frame(Timestamp::ZERO, Bytes::from(section(1)))
.unwrap();
assert_eq!(rig.media(2_500, 0x0011).await, 1, "the lead emission closes");
rig.si_track
.write_frame(Timestamp::ZERO, Bytes::from(section(2)))
.unwrap();
assert_eq!(rig.media(2_400, 0x0011).await, 0, "a reordered frame earns no credit");
assert_eq!(rig.media(3_400, 0x0011).await, 1, "only the prior revision closes");
assert_eq!(
rig.media(3_500, 0x0011).await,
0,
"the deferred revision enters the span at the floor"
);
assert_eq!(rig.media(3_600, 0x0011).await, 1, "the next span closes the revision");
assert_eq!(rig.exporter.discontinuity(), 0, "B-frame reordering is not a rewind");
}
#[tokio::test(start_paused = true)]
async fn si_anchor_survives_a_zero_interval_reorder() {
let section = |version: u8| make_long_section(0x42, 1, version, 0, 0, &[version; 8]);
let mut rig = si_cadence_rig(0x0011, 0x42, Duration::ZERO).await;
rig.si_track
.write_frame(Timestamp::ZERO, Bytes::from(section(0)))
.unwrap();
assert_eq!(rig.media(1_000, 0x0011).await, 0, "the first span stays buffered");
assert_eq!(rig.media(3_000, 0x0011).await, 1, "the anchor advances to 3s");
assert_eq!(rig.media(2_900, 0x0011).await, 0, "the reordered span stays open");
rig.catalog
.modify()
.unwrap()
.ext
.mpegts
.si
.get_mut(&0x0011)
.unwrap()
.get_mut(&0x42)
.unwrap()
.interval = Some(Duration::from_secs(1));
assert_eq!(
rig.media(3_400, 0x0011).await,
2,
"both zero-interval frames close together"
);
assert_eq!(rig.media(3_900, 0x0011).await, 0, "still in the anchor's slot");
assert_eq!(rig.media(4_000, 0x0011).await, 0, "the due table enters the open span");
assert_eq!(rig.media(4_100, 0x0011).await, 1, "the next span closes the due table");
assert_eq!(rig.exporter.discontinuity(), 0, "B-frame reordering is not a rewind");
}
#[tokio::test(start_paused = true)]
async fn si_rapid_revisions_are_rate_bounded() {
let section = |version: u8| make_long_section(0x42, 1, version, 0, 0, &[version; 8]);
let mut rig = si_cadence_rig(0x0011, 0x42, Duration::from_secs(30)).await;
let mut emitted = 0;
for i in 0..13u8 {
rig.si_track
.write_frame(Timestamp::ZERO, Bytes::from(section(i)))
.unwrap();
let frames = rig.media_frames(u64::from(i) * 250).await;
let count = count_pid(&frames, 0x0011);
emitted += count;
if i == 5 {
assert_eq!(count, 1, "the next span closes the 1s emission");
let needle = section(4);
assert!(
frames
.iter()
.any(|f| f.payload.windows(needle.len()).any(|w| w == needle)),
"the floored emission carries the newest revision"
);
}
}
emitted += rig.media(3_250, 0x0011).await;
assert_eq!(emitted, 4, "lead plus one per second, not one per revision");
}
#[tokio::test(start_paused = true)]
async fn overlapping_capture_teardown_keeps_the_survivors_mapping() {
let sdt = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 4]);
let mut input = si_packet(0x0011, &sdt);
input.extend_from_slice(&si_packet_cc(0x0011, &sdt, 1));
let mut broadcast = moq_net::broadcast::Info::new().produce();
let _consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut old = crate::container::ts::Import::new(broadcast.clone(), catalog.reserve());
old.decode(&BytesMut::from(&input[..])).unwrap();
let mut new = crate::container::ts::Import::new(broadcast, catalog.reserve());
new.decode(&BytesMut::from(&input[..])).unwrap();
let survivor = catalog.snapshot().ext.mpegts.si[&0x0011][&0x42].track.clone();
assert_ne!(survivor, "0x0011-0x42.si", "the replacement fell back to a unique name");
drop(old);
assert_eq!(
catalog.snapshot().ext.mpegts.si[&0x0011][&0x42].track,
survivor,
"the old capture's teardown left the survivor's mapping in place"
);
}
#[tokio::test(start_paused = true)]
async fn contiguous_same_version_commit_replaces_stale_sections() {
let sdt = |number: u8, last: u8, fill: u8| make_long_section(0x42, 1, 0, number, last, &[fill; 4]);
let mut input = si_packet(0x0011, &sdt(0, 2, 0xa0));
input.extend_from_slice(&si_packet_cc(0x0011, &sdt(1, 2, 0xa1), 1));
input.extend_from_slice(&si_packet_cc(0x0011, &sdt(2, 2, 0xa2), 2));
let b0 = sdt(0, 1, 0xb0);
let b1 = sdt(1, 1, 0xb1);
input.extend_from_slice(&si_packet_cc(0x0011, &b0, 3));
input.extend_from_slice(&si_packet_cc(0x0011, &b1, 4));
let mut rig = si_rig();
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
rig.import.finish().unwrap();
let si = rig.catalog.snapshot().ext.mpegts.si.clone();
assert_eq!(
read_si_sections(&rig.consumer, &si[&0x0011][&0x42].track).await,
vec![Bytes::from(b0), Bytes::from(b1)],
"the complete new generation retired the stale third section"
);
}
#[tokio::test(start_paused = true)]
async fn debounce_opens_without_a_media_clock() {
let sdt = |version: u8, fill: u8| make_long_section(0x42, 1, version, 0, 0, &[fill; 4]);
let mut rig = si_rig();
let mut input = si_packet(0x0011, &sdt(0, 0xaa));
input.extend_from_slice(&si_packet_cc(0x0011, &sdt(0, 0xaa), 1));
rig.import.decode(&BytesMut::from(&input[..])).unwrap();
let name = rig.catalog.snapshot().ext.mpegts.si[&0x0011][&0x42].track.clone();
let track = rig.consumer.track(&name).unwrap().subscribe(None).await.unwrap();
assert_eq!(track.latest(), Some(0), "the first snapshot cut immediately");
rig.import
.decode(&BytesMut::from(&si_packet_cc(0x0011, &sdt(1, 0xbb), 2)[..]))
.unwrap();
assert_eq!(track.latest(), Some(0), "a revision inside the window is held");
std::thread::sleep(Duration::from_millis(1200));
rig.import
.decode(&BytesMut::from(&si_packet_cc(0x0011, &sdt(1, 0xbb), 3)[..]))
.unwrap();
assert_eq!(track.latest(), Some(1), "the held revision cut after the window");
}
fn collect_pcrs(frames: &[Frame]) -> Vec<(usize, u64, u128)> {
let mut at = 0;
let mut pcrs = Vec::new();
for frame in frames {
for packet in frame.payload.chunks(188) {
if packet[3] & 0x30 == 0x20 && packet[5] & 0x10 != 0 {
let base = (u64::from(packet[6]) << 25)
| (u64::from(packet[7]) << 17)
| (u64::from(packet[8]) << 9)
| (u64::from(packet[9]) << 1)
| u64::from(packet[10] >> 7);
pcrs.push((at, base, frame.timestamp.as_micros()));
}
at += 1;
}
}
pcrs
}
async fn export_cbr_video() -> Vec<Frame> {
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 track = broadcast
.create_track(
broadcast.unique_name(".h264"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
{
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: true,
});
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.video
.renditions
.insert(track.name().to_string(), cfg);
}
let mut video = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
for i in 0..100u64 {
let keyframe = i % 25 == 0;
let mut nal = vec![if keyframe { 0x65u8 } else { 0x41 }];
nal.extend(std::iter::repeat_n(0xAB, 7_000));
if keyframe && i > 0 {
video.cut(None).unwrap();
}
video
.write(Frame {
timestamp: Timestamp::from_micros(i * 40_000).unwrap(),
duration: None,
payload: if keyframe {
annexb(&[SPS, PPS, &nal])
} else {
annexb(&[&nal])
},
keyframe,
})
.unwrap();
}
video.finish().unwrap();
let mut exporter = Export::new(crate::source::announced(&consumer))
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
drain_frames(&mut exporter).await
}
#[tokio::test(start_paused = true)]
async fn pcr_rides_the_bytes_it_labels() {
let frames = export_cbr_video().await;
let pcrs = collect_pcrs(&frames);
assert!(pcrs.len() > 100, "expected the full feed, got {} PCRs", pcrs.len());
let step = PCR_INTERVAL.as_micros() as u64 * 90 / 1_000;
for (i, w) in pcrs.windows(2).enumerate() {
assert_eq!(w[1].1.wrapping_sub(w[0].1) & ((1 << 33) - 1), step, "value step at {i}");
}
let gaps: Vec<usize> = pcrs.windows(2).map(|w| w[1].0 - w[0].0).collect();
assert_eq!(
gaps.iter().filter(|&&gap| gap == 1).count(),
0,
"no clock packet may sit adjacent to the previous one: {gaps:?}"
);
let mut sorted = gaps.clone();
sorted.sort_unstable();
let median = sorted[sorted.len() / 2];
assert!(
sorted[0] * 3 >= median && sorted[sorted.len() - 1] <= median * 3,
"packet gaps must track the interval the values assert, got {sorted:?}"
);
}
#[tokio::test(start_paused = true)]
async fn pcr_stamps_step_by_the_grid() {
let frames = export_cbr_video().await;
let pcrs = collect_pcrs(&frames);
let steps: Vec<i128> = pcrs.windows(2).map(|w| w[1].2 as i128 - w[0].2 as i128).collect();
let interval = PCR_INTERVAL.as_micros() as i128;
assert!(
steps.iter().all(|&step| step == interval),
"every clock packet must be stamped one grid interval past the last: {steps:?}"
);
}
#[tokio::test(start_paused = true)]
async fn pcr_stays_among_the_bytes_across_reordered_tracks() {
let data = include_bytes!("test_data/scte35/kyrion_dirtystart.ts");
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&data[..])).unwrap();
import.finish().unwrap();
let mut exporter = Export::new(crate::source::announced(&consumer))
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
let frames = drain_frames(&mut exporter).await;
let pcrs = collect_pcrs(&frames);
assert!(pcrs.len() > 50, "expected the full feed, got {} PCRs", pcrs.len());
let gaps: Vec<usize> = pcrs.windows(2).map(|w| w[1].0 - w[0].0).collect();
assert_eq!(
gaps.iter().filter(|&&gap| gap == 1).count(),
0,
"no clock packet may sit adjacent to the previous one: {gaps:?}"
);
let interval = PCR_INTERVAL.as_micros() as i128;
let off = pcrs
.windows(2)
.filter(|w| w[1].2 as i128 - w[0].2 as i128 != interval)
.count();
assert!(off <= 1, "{off} clock packets are stamped off the grid");
}
#[tokio::test(start_paused = true)]
async fn payload_less_clock_packets_repeat_the_counter() {
let frames = export_cbr_video().await;
let ts: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
assert_packet_aligned(&ts);
let mut last: std::collections::HashMap<u16, u8> = std::collections::HashMap::new();
let mut advanced = 0;
let mut discontinuities = 0;
for (i, packet) in ts.chunks(188).enumerate() {
let pid = u16::from(packet[1] & 0x1f) << 8 | u16::from(packet[2]);
let cc = packet[3] & 0x0f;
let payload = packet[3] & 0x10 != 0;
if let Some(&prev) = last.get(&pid) {
if payload && cc != (prev + 1) & 0x0f {
discontinuities += 1;
eprintln!("discontinuity at packet {i} on pid {pid}: {prev} -> {cc}");
} else if !payload && cc != prev {
advanced += 1;
eprintln!("payload-less packet {i} on pid {pid} advanced: {prev} -> {cc}");
}
}
last.insert(pid, cc);
}
assert_eq!(advanced, 0, "payload-less packets must repeat the counter");
assert_eq!(discontinuities, 0, "the counter must be continuous on every PID");
}
struct Clocked {
packets: usize,
ticks: u64,
nulls: usize,
max_gap: u64,
}
impl Clocked {
fn of(ts: &[u8]) -> Self {
const WRAP: u64 = (1 << 33) * 300;
let mut first = None;
let mut last = None;
let mut nulls = 0;
let mut max_gap = 0;
let mut span = 0;
for (index, packet) in ts.chunks(188).enumerate() {
let pid = (u16::from(packet[1] & 0x1f) << 8) | u16::from(packet[2]);
if pid == 0x1fff {
nulls += 1;
}
if packet[3] & 0x20 != 0 && packet[4] >= 7 && packet[5] & 0x10 != 0 {
let base = (u64::from(packet[6]) << 25)
| (u64::from(packet[7]) << 17)
| (u64::from(packet[8]) << 9)
| (u64::from(packet[9]) << 1)
| (u64::from(packet[10]) >> 7);
let ticks = base * 300 + ((u64::from(packet[10] & 1) << 8) | u64::from(packet[11]));
if let Some(prev) = last.replace((index, ticks)) {
let gap = (ticks + WRAP - prev.1) % WRAP;
max_gap = max_gap.max(gap);
span += gap;
}
first.get_or_insert(index);
}
}
let (first, last) = (first.expect("no PCR"), last.expect("no PCR"));
Self {
packets: last.0 - first,
ticks: span,
nulls,
max_gap,
}
}
fn rate(&self) -> u64 {
(self.packets as u128 * 188 * 8 * 27_000_000 / u128::from(self.ticks)) as u64
}
}
async fn export_fixture(data: &[u8], mux_rate: Option<u64>) -> BytesMut {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(data).unwrap();
import.finish().unwrap();
let mut export = export_of(&consumer).await;
if let Some(mux_rate) = mux_rate {
export = export.with_mux_rate(mux_rate);
}
let ts = drain_with(export).await;
assert_packet_aligned(&ts);
ts
}
#[tokio::test(start_paused = true)]
async fn export_pads_to_the_recorded_mux_rate() {
let ts = export_fixture(include_bytes!("test_data/bbb_cbr.ts"), None).await;
let clocked = Clocked::of(&ts);
assert!(clocked.nulls > 0, "no null stuffing was emitted");
let rate = clocked.rate();
assert!(
rate.abs_diff(400_000) * 100 <= 400_000,
"output runs at {rate} b/s, not the recorded 400 kb/s"
);
assert!(
clocked.max_gap <= 40 * 27_000,
"PCR gap of {} ticks exceeds 40 ms",
clocked.max_gap
);
}
#[tokio::test(start_paused = true)]
async fn export_mux_rate_override_beats_the_catalog() {
let ts = export_fixture(include_bytes!("test_data/bbb_cbr.ts"), Some(1_000_000)).await;
let rate = Clocked::of(&ts).rate();
assert!(
rate.abs_diff(1_000_000) * 100 <= 1_000_000,
"output runs at {rate} b/s, not the 1 Mb/s override"
);
}
#[tokio::test(start_paused = true)]
async fn export_mux_rate_override_is_bounded() {
for rate in [0, i64::MAX as u64] {
let ts = export_fixture(include_bytes!("test_data/bbb_cbr.ts"), Some(rate)).await;
assert_eq!(
Clocked::of(&ts).nulls,
0,
"null packets in an export with a refused override rate {rate}"
);
}
}
#[tokio::test(start_paused = true)]
async fn export_refuses_an_absurd_catalog_mux_rate() {
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().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let name = track.name().to_string();
catalog.modify().unwrap().audio.renditions.insert(name.clone(), {
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
cfg
});
catalog.modify().unwrap().ext.mpegts.mux_rate = Some(i64::MAX as u64);
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
for i in 0..500u64 {
producer
.write(Frame {
timestamp: Timestamp::from_micros(i * 20_000).unwrap(),
duration: None,
payload: Bytes::from(vec![i as u8; 16]),
keyframe: i % 50 == 0,
})
.unwrap();
}
producer.finish().unwrap();
let ts = drain_with(export_of(&consumer).await).await;
assert_packet_aligned(&ts);
assert_eq!(
Clocked::of(&ts).nulls,
0,
"null packets in an export with a refused catalog mux rate"
);
}
#[tokio::test(start_paused = true)]
async fn export_without_a_mux_rate_is_unpadded() {
let ts = export_fixture(include_bytes!("test_data/scte35/bbb5s.ts"), None).await;
assert_eq!(Clocked::of(&ts).nulls, 0, "null packets in an unpadded export");
}
#[tokio::test(start_paused = true)]
async fn export_stuffing_keeps_the_fractional_remainder() {
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 track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let name = track.name().to_string();
{
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog.modify().unwrap().audio.renditions.insert(name.clone(), cfg);
}
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
for i in 0..500u64 {
producer
.write(Frame {
timestamp: Timestamp::from_micros(i * 20_000).unwrap(),
duration: None,
payload: Bytes::from(vec![i as u8; 16]),
keyframe: i % 50 == 0,
})
.unwrap();
}
producer.finish().unwrap();
let export = Export::new(crate::source::announced(&consumer))
.await
.unwrap()
.with_mux_rate(1_000_000);
let ts = drain_with(export).await;
assert_packet_aligned(&ts);
let clocked = Clocked::of(&ts);
let slots = clocked.ticks / (PCR_INTERVAL.as_nanos() as u64 * 27 / 1000);
assert!(slots > 300, "expected a long run of slots, got {slots}");
let expected = (slots as u128 * 1_000_000 * PCR_INTERVAL.as_nanos() / (1_000_000_000 * 188 * 8)) as u64;
assert_eq!(
clocked.packets as u64, expected,
"{slots} slots at 1 Mb/s carry {expected} packets"
);
assert!(clocked.nulls > 0, "no null stuffing was emitted");
}
const IPB: [u64; GOP as usize] = [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13];
const PYRAMID: [u64; GOP as usize] = [0, 4, 2, 1, 3, 8, 6, 5, 7, 12, 10, 9, 11, 14, 13];
const REORDERED_BASE: u64 = 20 * GOP;
const FRAME_TICKS: u64 = 3_600;
fn reordered_at(frame: u64) -> Timestamp {
Timestamp::from_micros((REORDERED_BASE + frame) * VIDEO_US).unwrap()
}
struct Reordered {
_broadcast: moq_net::broadcast::Producer,
catalog: crate::catalog::Producer,
video: Producer<HangContainer>,
audio: Producer<HangContainer>,
source: crate::Source,
name: String,
pyramid: &'static [u64],
audio_index: u64,
}
impl Reordered {
fn new(sps: &'static [u8], pps: &'static [u8], pyramid: &'static [u64]) -> Self {
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 track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
let mut cfg = VideoConfig::new(H264 {
profile: sps[1],
constraints: sps[2],
level: sps[3],
inline: false,
});
cfg.container = Container::Legacy;
cfg.description =
Some(crate::codec::h264::build_avcc(&[Bytes::from_static(sps)], &[Bytes::from_static(pps)]).unwrap());
catalog.modify().unwrap().video.renditions.insert(name.clone(), cfg);
let video = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Video));
let track = broadcast
.create_track(
broadcast.unique_name(".aac"),
hang::container::track_info(hang::catalog::PRIORITY.audio),
)
.unwrap();
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog
.modify()
.unwrap()
.audio
.renditions
.insert(track.name().to_string(), cfg);
let audio = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Audio));
Self {
source: crate::source::announced(&consumer),
_broadcast: broadcast,
catalog,
video,
audio,
name,
pyramid,
audio_index: 0,
}
}
async fn export(&self, max_age: Duration) -> Export {
let mut export = Export::new(self.source.clone()).await.unwrap().with_max_age(max_age);
assert!(drain_frames(&mut export).await.is_empty());
export
}
fn video(&mut self, tick: u64) {
let (group, at) = (tick / GOP, (tick % GOP) as usize);
let layout = if self.pyramid.contains(&group) { &PYRAMID } else { &IPB };
let keyframe = at == 0;
let slice = if keyframe {
vec![0x65u8; 3_000]
} else {
vec![0x41u8; 400]
};
self.video
.write(Frame {
timestamp: reordered_at(group * GOP + layout[at]),
duration: None,
payload: length_prefixed(&[&slice]),
keyframe,
})
.unwrap();
}
fn audio_before(&mut self, frame: u64) -> bool {
let index = self.audio_index;
let timestamp = Timestamp::from_micros(REORDERED_BASE * VIDEO_US + index * AUDIO_US).unwrap();
if timestamp >= reordered_at(frame) {
return false;
}
self.audio
.write(Frame {
timestamp,
duration: None,
payload: Bytes::from_iter((0..180u16).map(|i| (i ^ index as u16) as u8)),
keyframe: index.is_multiple_of(AUDIO_GROUP),
})
.unwrap();
self.audio_index += 1;
true
}
fn publish_jitter(&mut self, jitter: Duration) {
let mut catalog = self.catalog.modify().unwrap();
catalog.video.renditions.get_mut(&self.name).unwrap().jitter = Some(jitter);
}
fn finish(&mut self) {
self.video.finish().unwrap();
self.audio.finish().unwrap();
}
}
async fn export_reordered(
sps: &'static [u8],
pps: &'static [u8],
pyramid: &'static [u64],
jitter: Option<(u64, Duration)>,
) -> (Vec<Frame>, Vec<Frame>) {
let mut rig = Reordered::new(sps, pps, pyramid);
let mut a = Export::new(rig.source.clone()).await.unwrap();
let mut b = None;
let (mut out_a, mut out_b) = (Vec::new(), Vec::new());
for tick in 0..TICKS {
if let Some((at, jitter)) = jitter
&& at == tick
{
rig.publish_jitter(jitter);
}
rig.video(tick);
while rig.audio_before(tick + 1) {}
out_a.extend(drain_frames(&mut a).await);
if let Some(b) = b.as_mut() {
out_b.extend(drain_frames(b).await);
}
if tick + 1 == JOIN {
b = Some(Export::new(rig.source.clone()).await.unwrap());
}
}
rig.finish();
let mut b = b.unwrap();
out_a.extend(drain_frames(&mut a).await);
out_b.extend(drain_frames(&mut b).await);
(out_a, out_b)
}
fn video_timing(frames: &[Frame], range: impl std::ops::RangeBounds<Timestamp>) -> Vec<(u64, Option<u64>)> {
let stamps: Vec<Timestamp> = frames
.iter()
.flat_map(|f| std::iter::repeat_n(f.timestamp, f.payload.len() / 188))
.collect();
let bytes: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
let mut reader = TsPacketReader::new(Cursor::new(bytes));
let mut out = Vec::new();
let mut index = 0;
while let Some(packet) = reader.read_ts_packet().unwrap() {
let stamp = stamps[index];
index += 1;
if !range.contains(&stamp) {
continue;
}
if let Some(TsPayload::PesStart(pes)) = packet.payload
&& (mpeg2ts::es::StreamId::VIDEO_MIN..=mpeg2ts::es::StreamId::VIDEO_MAX)
.contains(&pes.header.stream_id.as_u8())
{
let pts = pes.header.pts.expect("video PES carried no PTS").as_u64();
out.push((pts, pes.header.dts.map(|t| t.as_u64())));
}
}
out
}
fn assert_decodes_before_presenting(timing: &[(u64, Option<u64>)]) {
assert!(timing.len() > 20, "too few video frames to judge: {}", timing.len());
for (i, &(pts, dts)) in timing.iter().enumerate() {
let dts = dts.unwrap_or(pts);
assert!(dts <= pts, "frame {i} decodes at {dts}, after it is presented at {pts}");
}
}
fn assert_decodes_after_the_clock(frames: &[Frame]) {
let bytes: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
let mut reader = TsPacketReader::new(Cursor::new(bytes));
let mut last_pcr = None;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if let Some(af) = packet.adaptation_field.as_ref()
&& let Some(pcr) = af.pcr
{
let pcr = pcr.as_u64() / 300;
if let Some(last) = last_pcr {
assert!(
pcr > last || af.discontinuity_indicator,
"the clock steps back from {last} to {pcr} without a discontinuity"
);
}
last_pcr = Some(pcr);
}
if let Some(TsPayload::PesStart(pes)) = packet.payload
&& let Some(pcr) = last_pcr
{
let pts = pes.header.pts.expect("PES carried no PTS").as_u64();
let decode = pes.header.dts.map(|t| t.as_u64()).unwrap_or(pts);
assert!(decode >= pcr, "unit decodes at {decode}, before the clock at {pcr}");
}
}
}
#[tokio::test(start_paused = true)]
async fn declared_reorder_sizes_the_decode_clock_from_the_first_frame() {
let mut rig = Reordered::new(
crate::codec::h264::fixtures::SPS_IPB,
crate::codec::h264::fixtures::PPS,
&[],
);
let max_age = Duration::from_millis(500);
let (mut late, mut whole) = (rig.export(max_age).await, rig.export(max_age).await);
let (mut out_late, mut out_whole) = (Vec::new(), Vec::new());
for tick in 0..TICKS / 2 {
while rig.audio_before(tick + 2) {
out_late.extend(poll_frames(&mut late));
}
rig.video(tick);
out_late.extend(poll_frames(&mut late));
out_whole.extend(poll_frames(&mut whole));
}
rig.finish();
out_late.extend(drain_frames(&mut late).await);
out_whole.extend(drain_frames(&mut whole).await);
let late: Vec<u8> = out_late.iter().flat_map(|f| f.payload.iter().copied()).collect();
let whole: Vec<u8> = out_whole.iter().flat_map(|f| f.payload.iter().copied()).collect();
assert!(late.len() > 100 * 188, "too little output to compare: {}", late.len());
assert!(late == whole, "arrival order changed the rendering");
let timing = video_timing(&out_late, ..);
assert_decodes_before_presenting(&timing);
let (pts, dts) = timing[1];
assert_eq!(
pts,
(REORDERED_BASE + 2) * FRAME_TICKS,
"the first P-frame follows the keyframe"
);
assert_eq!(
dts,
Some(pts - FRAME_TICKS - 1),
"the first P-frame already runs on the declared reserve"
);
assert_decodes_after_the_clock(&out_late);
}
#[tokio::test(start_paused = true)]
async fn undeclared_reorder_converges_after_its_deepest_reorder() {
let (runner, joiner) = export_reordered(SPS, PPS, &[2, 7, 9], None).await;
let keyframes: Vec<Timestamp> = joiner.iter().filter(|f| f.keyframe).map(|f| f.timestamp).collect();
let deepest = reordered_at(8 * GOP);
assert!(
keyframes[1] < deepest,
"the joiner must render shallow groups before the deep one"
);
assert_ne!(
video_timing(&runner, keyframes[1]..deepest),
video_timing(&joiner, keyframes[1]..deepest),
"a joiner that has not muxed the deepest reordering runs the runner's clock"
);
assert_only_continuity_differs(&runner, &joiner, deepest);
for frames in [&runner, &joiner] {
assert_decodes_before_presenting(&video_timing(frames, deepest..));
assert_decodes_after_the_clock(frames);
}
}
#[tokio::test(start_paused = true)]
async fn jitter_published_after_the_tables_raises_the_reserve() {
let jitter = Duration::from_millis(80);
let (runner, joiner) = export_reordered(SPS, PPS, &[], Some((2 * GOP, jitter))).await;
let keyframes: Vec<Timestamp> = joiner.iter().filter(|f| f.keyframe).map(|f| f.timestamp).collect();
assert_only_continuity_differs(&runner, &joiner, keyframes[1]);
let ticks = 80 * 90;
let keyframe_timing: Vec<(u64, Option<u64>)> = video_timing(&runner, reordered_at(3 * GOP)..)
.into_iter()
.filter(|(pts, _)| (pts / FRAME_TICKS).is_multiple_of(GOP))
.collect();
assert!(
keyframe_timing.len() > 3,
"too few keyframes to judge: {keyframe_timing:?}"
);
for (pts, dts) in keyframe_timing {
assert_eq!(dts, Some(pts - ticks), "keyframe at {pts} ignores the published jitter");
}
assert_decodes_after_the_clock(&runner);
}
fn aac_rendition(
broadcast: &mut moq_net::broadcast::Producer,
catalog: &mut crate::catalog::Producer,
name: &str,
) -> Producer<HangContainer> {
let track = broadcast
.create_track(name, hang::container::track_info(hang::catalog::PRIORITY.audio))
.unwrap();
let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2);
cfg.container = Container::Legacy;
catalog.modify().unwrap().audio.renditions.insert(name.to_string(), cfg);
Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data))
}
fn write_aac(producer: &mut Producer<HangContainer>, ms: u64) {
producer
.write(Frame {
timestamp: Timestamp::from_millis(ms).unwrap(),
duration: None,
payload: Bytes::from_static(&[0x21, 0x10, 0x04, 0x60]),
keyframe: true,
})
.unwrap();
producer.cut(None).unwrap();
}
fn pes_count(frames: &[Frame]) -> usize {
let bytes: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
let mut reader = TsPacketReader::new(Cursor::new(bytes));
let mut count = 0;
while let Some(packet) = reader.read_ts_packet().unwrap() {
if matches!(packet.payload, Some(TsPayload::PesStart(_))) {
count += 1;
}
}
count
}
async fn drain_to_end<E: tscat::Catalog>(export: &mut Export<E>) -> (Vec<Frame>, crate::Result<()>) {
let mut out = Vec::new();
loop {
match tokio::time::timeout(Duration::from_secs(5), export.next())
.await
.expect("the export ends")
{
Ok(Some(frame)) => out.push(frame),
Ok(None) => return (out, Ok(())),
Err(err) => return (out, Err(err)),
}
}
}
#[tokio::test(start_paused = true)]
async fn a_track_leaving_the_catalog_is_read_to_its_end() {
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 kept = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
let mut leaving = aac_rendition(&mut broadcast, &mut catalog, "b.aac");
let mut export = Export::new(crate::source::announced(&consumer))
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
for ms in (0..200).step_by(20) {
write_aac(&mut kept, ms);
write_aac(&mut leaving, ms);
}
let mut frames = drain_frames(&mut export).await;
assert!(!frames.is_empty(), "the program started");
catalog.modify().unwrap().audio.renditions.remove("b.aac");
for ms in (200..300).step_by(20) {
write_aac(&mut kept, ms);
write_aac(&mut leaving, ms);
}
leaving.finish().unwrap();
for ms in (300..400).step_by(20) {
write_aac(&mut kept, ms);
}
kept.finish().unwrap();
catalog.finish().unwrap();
let (rest, end) = drain_to_end(&mut export).await;
end.expect("a track leaving the catalog is not a layout change");
frames.extend(rest);
assert_eq!(pes_count(&frames), 20 + 15, "every frame of both tracks went out");
}
async fn resume_after(finish: bool) {
let origin = crate::source::produce_origin();
let source = crate::Source::new(origin.consume(), "live");
let publish = || {
let mut broadcast = origin.publish("live", Default::default()).unwrap();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
(broadcast, catalog)
};
let (mut broadcast, mut catalog) = publish();
let mut track = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
let ended = source.broadcast().await.unwrap();
let mut export = Export::new(source.clone())
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
for ms in (0..200).step_by(20) {
write_aac(&mut track, ms);
}
let mut before = drain_frames(&mut export).await;
if finish {
track.finish().unwrap();
catalog.finish().unwrap();
}
drop((broadcast, catalog, track));
let (rest, end) = drain_to_end(&mut export).await;
before.extend(rest);
assert_eq!(end.is_ok(), finish, "a finish ends cleanly and a drop fails: {end:?}");
let (mut broadcast, mut catalog) = publish();
std::ops::DerefMut::deref_mut(&mut catalog.modify().unwrap());
source.returned(&ended).await.unwrap();
export.resume().await.unwrap();
let mut after = drain_frames(&mut export).await;
assert!(after.is_empty(), "nothing to carry before the track is listed");
let mut track = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
for ms in (0..200).step_by(20) {
write_aac(&mut track, ms);
}
after.extend(drain_frames(&mut export).await);
track.finish().unwrap();
catalog.finish().unwrap();
let (rest, end) = drain_to_end(&mut export).await;
after.extend(rest);
end.unwrap();
assert_eq!(export.discontinuity(), 1, "the return is one break");
assert_eq!(count_discontinuity(&before), 0);
assert_eq!(count_discontinuity(&after), 1, "the break is flagged once");
assert!(count_pid(&after, 0x0000) >= 1, "PAT re-emitted after the return");
assert_eq!(pes_count(&after), 10, "the returned broadcast's frames all went out");
}
#[tokio::test(start_paused = true)]
async fn resume_does_not_inherit_an_expired_stall() {
let max_age = Duration::from_secs(2);
let origin = crate::source::produce_origin();
let source = crate::Source::new(origin.consume(), "live");
let publish = || {
let mut broadcast = origin.publish("live", Default::default()).unwrap();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
(broadcast, catalog)
};
async fn quick(export: &mut Export, max_age: Duration) -> Vec<Frame> {
let mut out = Vec::new();
while let Ok(frame) = tokio::time::timeout(max_age / 10, export.next()).await {
out.extend(frame.expect("exporter error"));
}
out
}
let (mut broadcast, mut catalog) = publish();
let mut leading = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
let mut quiet = aac_rendition(&mut broadcast, &mut catalog, "b.aac");
let ended = source.broadcast().await.unwrap();
let mut export = Export::new(source.clone()).await.unwrap().with_max_age(max_age);
write_aac(&mut quiet, 0);
for ms in (0..200).step_by(20) {
write_aac(&mut leading, ms);
}
quick(&mut export, max_age).await;
tokio::time::advance(max_age).await;
assert!(
!quick(&mut export, max_age).await.is_empty(),
"the leading track went around the quiet one"
);
drop((broadcast, catalog, leading, quiet));
let (_, end) = drain_to_end(&mut export).await;
assert!(end.is_err(), "a drop fails the export");
let (mut broadcast, mut catalog) = publish();
let mut leading = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
let mut quiet = aac_rendition(&mut broadcast, &mut catalog, "b.aac");
source.returned(&ended).await.unwrap();
export.resume().await.unwrap();
for ms in (0..200).step_by(20) {
write_aac(&mut leading, ms);
}
assert_eq!(
pes_count(&quick(&mut export, max_age).await),
0,
"the replacement went around its quiet track on the old broadcast's budget"
);
write_aac(&mut quiet, 200);
assert!(
!quick(&mut export, max_age).await.is_empty(),
"the replacement went out once both tracks showed"
);
}
#[tokio::test(start_paused = true)]
async fn resume_after_a_finish() {
resume_after(true).await;
}
#[tokio::test(start_paused = true)]
async fn resume_after_a_drop() {
resume_after(false).await;
}
#[tokio::test(start_paused = true)]
async fn export_stats_skip_output_a_failure_discards() {
let origin = crate::source::produce_origin();
let source = crate::Source::new(origin.consume(), "live");
let publish = || {
let mut broadcast = origin.publish("live", Default::default()).unwrap();
let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap();
(broadcast, catalog)
};
let units = |stats: Stats| stats.streams.values().map(|row| row.units).sum::<u64>() as usize;
let (mut broadcast, mut catalog) = publish();
let mut track = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
let ended = source.broadcast().await.unwrap();
let mut export = Export::new(source.clone())
.await
.unwrap()
.with_max_age(RECORDING_MAX_AGE);
for ms in (0..200).step_by(20) {
write_aac(&mut track, ms);
}
let mut frames = drain_frames(&mut export).await;
track
.write(Frame {
timestamp: Timestamp::from_millis(200).unwrap(),
duration: None,
payload: Bytes::from(vec![0; 8185]),
keyframe: true,
})
.unwrap();
let (rest, end) = drain_to_end(&mut export).await;
frames.extend(rest);
assert!(end.is_err(), "an unframeable AAC frame fails the export");
assert!(pes_count(&frames) < 10, "the span before the failure stayed queued");
assert_eq!(units(export.stats()), pes_count(&frames));
drop((broadcast, catalog, track));
let (mut broadcast, mut catalog) = publish();
let mut track = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
source.returned(&ended).await.unwrap();
export.resume().await.unwrap();
for ms in (0..200).step_by(20) {
write_aac(&mut track, ms);
}
frames.extend(drain_frames(&mut export).await);
track.finish().unwrap();
catalog.finish().unwrap();
let (rest, end) = drain_to_end(&mut export).await;
frames.extend(rest);
end.unwrap();
assert_eq!(
units(export.stats()),
pes_count(&frames),
"the discarded span never counts"
);
}
async fn export_liveness(sample: u64, stop: u64) -> (Stats, Stats, Vec<Frame>) {
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 track = broadcast
.create_track("video.avc1", hang::container::track_info(hang::catalog::PRIORITY.video))
.unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x42,
constraints: 0xc0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description =
Some(crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap());
catalog
.modify()
.unwrap()
.video
.renditions
.insert(track.name().to_string(), cfg);
let mut video = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
let mut audio = [
aac_rendition(&mut broadcast, &mut catalog, "primary.aac"),
aac_rendition(&mut broadcast, &mut catalog, "secondary.aac"),
];
let mut export = Export::new(crate::source::announced(&consumer)).await.unwrap();
assert!(export.stats().is_empty(), "no row before the program tables");
let (mut sampled, mut frames) = (None, Vec::new());
let mut audio_index = 0;
for tick in 0..TICKS {
if tick < stop {
let keyframe = tick % GOP == 0;
let slice = if keyframe {
vec![0x65u8; 3_000]
} else {
vec![0x41u8; 400]
};
video
.write(Frame {
timestamp: Timestamp::from_micros(tick * VIDEO_US).unwrap(),
duration: None,
payload: length_prefixed(&[&slice]),
keyframe,
})
.unwrap();
}
while audio_index * AUDIO_US < (tick + 1) * VIDEO_US {
let running = if tick < stop { &mut audio[..] } else { &mut audio[1..] };
for track in running {
track
.write(Frame {
timestamp: Timestamp::from_micros(audio_index * AUDIO_US).unwrap(),
duration: None,
payload: Bytes::from_iter((0..180u16).map(|i| (i ^ audio_index as u16) as u8)),
keyframe: audio_index % AUDIO_GROUP == 0,
})
.unwrap();
}
audio_index += 1;
}
let out = drain_frames(&mut export).await;
if sampled.is_some() {
frames.extend(out);
}
if tick + 1 == sample {
sampled = Some(export.stats());
}
}
(sampled.expect("sampled mid-run"), export.stats(), frames)
}
#[tokio::test(start_paused = true)]
async fn export_stats_advance_every_stream() {
let (mid, end, _) = export_liveness(TICKS / 2, TICKS).await;
let tracks: Vec<&str> = end.streams.values().map(|row| row.track).collect();
assert_eq!(tracks, [".aac", ".aac", ".avc3"], "one row per elementary stream");
for (pid, row) in &end.streams {
let before = &mid.streams[pid];
assert!(before.units > 0, "PID {pid:#x} wrote nothing by mid-run");
assert!(
row.units > before.units,
"PID {pid:#x} stopped advancing: {before:?} -> {row:?}"
);
let quiet = row.quiet.expect("the output carries a PCR");
assert!(quiet < Duration::from_millis(200), "PID {pid:#x} quiet for {quiet:?}");
assert_eq!((row.resyncs, row.discarded, row.unconfirmed), (0, 0, 0));
}
}
#[tokio::test(start_paused = true)]
async fn export_stats_catch_a_track_that_stops() {
let stop = TICKS / 2;
let (sampled, end, after) = export_liveness(stop + 5, stop).await;
let stalled = Duration::from_micros((TICKS - stop) * VIDEO_US);
let advanced = |pid: &u16| end.streams[pid].units > sampled.streams[pid].units;
let (running, stopped): (Vec<u16>, Vec<u16>) = end.streams.keys().copied().partition(advanced);
let [running] = running[..] else {
panic!("expected one stream to keep advancing, got {running:?}: {end:?}");
};
assert_eq!(end.streams[&running].track, ".aac");
assert_eq!(stopped.len(), 2, "video and the primary audio stopped");
let quiet = |pid: u16| end.streams[&pid].quiet.expect("the output carries a PCR");
assert!(quiet(running) < Duration::from_millis(200), "{:?}", quiet(running));
for pid in stopped {
assert!(
quiet(pid) > stalled - Duration::from_millis(200),
"PID {pid:#x} quiet for only {:?} of a {stalled:?} stall",
quiet(pid)
);
}
assert!(
count_pid(&after, 0x0000) >= 3,
"the PAT kept repeating through the stall"
);
assert!(count_pid(&after, running) > 0, "the surviving track kept flowing");
}
#[tokio::test(start_paused = true)]
async fn export_stats_count_a_gap_longer_than_the_backfill() {
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 stopped = aac_rendition(&mut broadcast, &mut catalog, "a.aac");
let mut running = aac_rendition(&mut broadcast, &mut catalog, "b.aac");
let mut export = Export::new(crate::source::announced(&consumer)).await.unwrap();
for ms in (0..2_000).step_by(20) {
write_aac(&mut stopped, ms);
write_aac(&mut running, ms);
drain_frames(&mut export).await;
}
for ms in (7_000..8_000).step_by(20) {
write_aac(&mut running, ms);
drain_frames(&mut export).await;
}
let stats = export.stats();
let mut rows: Vec<_> = stats.streams.values().collect();
rows.sort_by_key(|row| row.units);
let [stopped, running] = rows[..] else {
panic!("expected two rows: {stats:?}");
};
assert!(stopped.units < running.units, "{stats:?}");
let quiet = stopped.quiet.expect("the output carries a PCR");
assert!(
quiet > Duration::from_millis(5_800),
"stopped at 2 s, quiet for only {quiet:?} at 8 s"
);
let quiet = running.quiet.expect("the output carries a PCR");
assert!(quiet < Duration::from_millis(200), "{quiet:?}");
}