use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use core::marker::PhantomData;
use broadcast_common::{Package, Parse, Unpackage};
use crate::ac3::{Ac3SpecificBox, Ec3SpecificBox};
use broadcast_hls::{ByteRange, MapTag, MediaPlaylist, MediaSegment};
use crate::ac4::Ac4SpecificBox;
use crate::box_types::{BOX_HEADER_MIN_SIZE, parse_box};
use crate::dts::DtsSpecificBox;
use crate::error::{Error, Result};
use crate::flac::FlacSpecificBox;
use crate::init_segment::{MovieBox, OpaqueBox, SampleEntryVariant, StblChild, TrackBox};
use crate::ir::{CodecConfig, FragmentTrackData, Sample, SubtitleFormat, TrackSpec};
use crate::movie_fragment::MovieFragmentBox;
use crate::mp4esds::EsdsBox;
use crate::mpeg_legacy::{Mpeg2SeqHeader, MpegAudioFrameHeader, MpegAudioLayer};
use crate::mpegh::{MHAC_FOURCC, MHADecoderConfigurationRecord};
use crate::opus::OpusSpecificBox;
use crate::pipeline::{build_init_segment, build_media_segment};
pub use crate::ir::{Media, PcrSample, SkippedTrack, Track, TrackEncryption};
const SAMPLE_FLAG_IS_NON_SYNC: u32 = 0x0001_0000;
const DEFAULT_MOVIE_TIMESCALE: u32 = 1000;
const OTI_MPEG2_AUDIO: u8 = 0x69;
const OTI_MPEG1_AUDIO: u8 = 0x6B;
#[derive(Debug, Default, Clone)]
pub struct Fmp4Demux<'a> {
_marker: PhantomData<&'a [u8]>,
}
impl Fmp4Demux<'_> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
struct TrackBuilder {
spec: TrackSpec,
samples: Vec<Sample>,
start_decode_time: Option<u64>,
next_dts: i64,
}
impl<'a> Unpackage for Fmp4Demux<'a> {
type Input = &'a [u8];
type Media = Media;
type Error = Error;
fn unpackage(&mut self, input: &'a [u8]) -> Result<Media> {
let moov_bytes =
find_top_box(input, b"moov").ok_or(Error::UnexpectedBox { expected: "moov" })?;
let moov = MovieBox::parse(moov_bytes)?;
let movie_timescale = moov.mvhd.timescale;
let mut builders: Vec<TrackBuilder> = Vec::with_capacity(moov.tracks.len());
let mut skipped: Vec<SkippedTrack> = Vec::new();
for trak in &moov.tracks {
match track_spec_from_trak(trak) {
Ok(spec) => builders.push(TrackBuilder {
spec,
samples: Vec::new(),
start_decode_time: None,
next_dts: 0,
}),
Err(err) => skipped.push(skipped_track(err)),
}
}
let mut offset = 0usize;
let mut pending_moof: Option<(usize, MovieFragmentBox)> = None;
while offset + BOX_HEADER_MIN_SIZE <= input.len() {
let (bx, consumed) = parse_box(&input[offset..])?;
let ty = bx.header.box_type.0;
if &ty == b"moof" {
let moof = MovieFragmentBox::parse_body(bx.body)?;
pending_moof = Some((offset, moof));
} else if &ty == b"mdat" {
if let Some((moof_off, moof)) = pending_moof.take() {
absorb_fragment(input, moof_off, &moof, &mut builders)?;
}
}
if consumed == 0 {
break;
}
offset += consumed;
}
let tracks = builders
.into_iter()
.map(|mut b| {
refine_legacy_config(&mut b.spec.config, &b.samples);
Track {
spec: b.spec,
samples: b.samples,
start_decode_time: b.start_decode_time.unwrap_or(0),
encryption: None,
}
})
.collect();
Ok(Media {
tracks,
movie_timescale,
pcr: Vec::new(),
skipped,
})
}
}
fn absorb_fragment(
file: &[u8],
moof_off: usize,
moof: &MovieFragmentBox,
builders: &mut [TrackBuilder],
) -> Result<()> {
for traf in &moof.traf {
let tfhd = &traf.tfhd;
let Some(builder) = builders
.iter_mut()
.find(|b| b.spec.track_id == tfhd.track_id)
else {
continue;
};
if let Some(tfdt) = &traf.tfdt {
let base = tfdt.base_media_decode_time();
if builder.start_decode_time.is_none() {
builder.start_decode_time = Some(base);
}
builder.next_dts = base as i64;
}
for trun in &traf.trun {
let base = moof_off as i64 + trun.data_offset.unwrap_or(0) as i64;
let mut cursor = base;
for (i, ts) in trun.samples.iter().enumerate() {
let size = ts
.sample_size
.or(tfhd.default_sample_size)
.ok_or(Error::InvalidInput(
"trun sample has no size (no trun.sample_size, no tfhd default_sample_size)",
))? as usize;
let duration = ts
.sample_duration
.or(tfhd.default_sample_duration)
.unwrap_or(0);
let flags = ts
.sample_flags
.or(if i == 0 {
trun.first_sample_flags
} else {
None
})
.or(tfhd.default_sample_flags)
.unwrap_or(0);
let is_sync = flags & SAMPLE_FLAG_IS_NON_SYNC == 0;
let composition_offset = ts.sample_composition_time_offset.unwrap_or(0) as i64;
let start = usize::try_from(cursor)
.map_err(|_| Error::InvalidInput("negative sample data offset"))?;
let end = start + size;
if end > file.len() {
return Err(Error::BufferTooShort {
need: end,
have: file.len(),
what: "fragment sample data",
});
}
let dts = builder.next_dts;
let pts = dts + composition_offset;
builder.samples.push(Sample {
data: file[start..end].to_vec().into(),
dts: Some(dts),
pts: Some(pts),
duration: Some(duration),
flags: crate::ir::SampleFlags::new(is_sync),
provenance: None,
});
builder.next_dts += duration as i64;
cursor += size as i64;
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct CmafMux {
pub sequence_number: u32,
}
impl Default for CmafMux {
fn default() -> Self {
Self { sequence_number: 1 }
}
}
impl CmafMux {
pub fn new(sequence_number: u32) -> Self {
Self { sequence_number }
}
}
impl Package for CmafMux {
type Media = Media;
type Output = Vec<u8>;
type Error = Error;
fn package(&mut self, media: &Media) -> Result<Vec<u8>> {
if media.tracks.is_empty() {
return Err(Error::InvalidInput("cannot package a Media with no tracks"));
}
let specs: Vec<TrackSpec> = media.tracks.iter().map(|t| t.spec.clone()).collect();
let movie_timescale = if media.movie_timescale == 0 {
DEFAULT_MOVIE_TIMESCALE
} else {
media.movie_timescale
};
let mut out = build_init_segment(&specs, movie_timescale)?;
let fragments: Vec<FragmentTrackData<'_>> = media
.tracks
.iter()
.map(|t| FragmentTrackData {
track_id: t.spec.track_id,
base_media_decode_time: t.start_decode_time,
samples: &t.samples,
})
.collect();
let media_seg = build_media_segment(self.sequence_number, &fragments)?;
out.extend_from_slice(&media_seg);
Ok(out)
}
}
#[derive(Debug, Clone)]
pub struct HlsPackager {
pub version: u8,
pub media_sequence: u64,
pub uri_prefix: String,
}
impl Default for HlsPackager {
fn default() -> Self {
Self {
version: 7,
media_sequence: 0,
uri_prefix: String::from("seg"),
}
}
}
impl Package for HlsPackager {
type Media = Media;
type Output = String;
type Error = Error;
fn package(&mut self, media: &Media) -> Result<String> {
if media.tracks.is_empty() {
return Err(Error::InvalidInput("cannot package a Media with no tracks"));
}
let kept: Vec<&Track> = media
.tracks
.iter()
.filter(|t| t.samples.iter().any(|s| s.duration.is_some()))
.collect();
let specs: Vec<TrackSpec> = kept.iter().map(|t| t.spec.clone()).collect();
let movie_timescale = if media.movie_timescale == 0 {
DEFAULT_MOVIE_TIMESCALE
} else {
media.movie_timescale
};
let init_len = if specs.is_empty() {
0
} else {
build_init_segment(&specs, movie_timescale)?.len() as u64
};
let mut segments = Vec::with_capacity(kept.len());
let mut target_secs = 0u32;
for t in kept {
let ticks: u64 = t
.samples
.iter()
.map(|s| s.duration.unwrap_or(0) as u64)
.sum();
let ts = if t.spec.timescale == 0 {
1
} else {
t.spec.timescale
} as u64;
let ceil_secs = ticks.div_ceil(ts) as u32;
if ceil_secs > target_secs {
target_secs = ceil_secs;
}
let uri = format!("{}{}.m4s", self.uri_prefix, t.spec.track_id);
segments.push(MediaSegment {
map: Some(MapTag {
uri: uri.clone(),
byte_range: Some(ByteRange {
length: init_len,
offset: Some(0),
}),
extra_attrs: Vec::new(),
}),
uri,
duration: ticks as f64 / ts as f64,
discontinuous: false,
parts: vec![],
..Default::default()
});
}
if segments.is_empty() {
return Err(Error::InvalidInput(
"cannot package a Media whose every track is timestamp-less \
(section-carried): an HLS media playlist needs at least one \
segment with a real EXTINF duration",
));
}
let playlist = MediaPlaylist {
version: self.version,
target_duration: target_secs,
media_sequence: self.media_sequence,
discontinuity_sequence: 0,
segments,
endlist: true,
extra_tags: vec![],
low_latency: None,
iframes_only: false,
open_segment: None,
..Default::default()
};
Ok(playlist.to_m3u8())
}
}
pub(crate) fn find_top_box<'a>(data: &'a [u8], fourcc: &[u8; 4]) -> Option<&'a [u8]> {
let mut offset = 0usize;
while offset + BOX_HEADER_MIN_SIZE <= data.len() {
let (bx, consumed) = parse_box(&data[offset..]).ok()?;
if &bx.header.box_type.0 == fourcc {
let end = if bx.header.size == 0 {
data.len()
} else {
offset + bx.header.size as usize
};
return Some(&data[offset..end]);
}
if consumed == 0 {
break;
}
offset += consumed;
}
None
}
pub(crate) fn track_spec_from_trak(trak: &TrackBox) -> Result<TrackSpec> {
let track_id = trak.tkhd.track_id;
let mdia = trak
.mdia
.as_ref()
.ok_or(Error::UnexpectedBox { expected: "mdia" })?;
let timescale = mdia
.mdhd
.as_ref()
.ok_or(Error::UnexpectedBox { expected: "mdhd" })?
.timescale;
let minf = mdia
.minf
.as_ref()
.ok_or(Error::UnexpectedBox { expected: "minf" })?;
let stbl = minf
.stbl
.as_ref()
.ok_or(Error::UnexpectedBox { expected: "stbl" })?;
let stsd = stbl
.children
.iter()
.find_map(|c| match c {
StblChild::Stsd(s) => Some(s),
_ => None,
})
.ok_or(Error::UnexpectedBox { expected: "stsd" })?;
let entry = stsd.entries.first().ok_or(Error::UnexpectedBox {
expected: "stsd entry",
})?;
let config = codec_config_from_entry(entry)?;
Ok(TrackSpec::new(track_id, timescale, config))
}
pub(crate) fn skipped_track(err: Error) -> SkippedTrack {
let fourcc = match &err {
Error::UnsupportedSampleEntry { fourcc } => fourcc.clone(),
_ => String::from("unknown"),
};
SkippedTrack {
fourcc,
reason: err.to_string(),
}
}
fn codec_config_from_entry(entry: &SampleEntryVariant) -> Result<CodecConfig> {
match entry {
SampleEntryVariant::Avc1(avc) => Ok(CodecConfig::Avc {
config: avc.config.clone(),
width: avc.visual.width,
height: avc.visual.height,
}),
SampleEntryVariant::Hevc1(hevc) => Ok(CodecConfig::Hevc {
config: hevc.config.clone(),
width: hevc.visual.width,
height: hevc.visual.height,
}),
SampleEntryVariant::Vvc(vvc) => {
let (width, height) = vvc
.config
.config
.dimensions()
.unwrap_or((vvc.visual.width, vvc.visual.height));
Ok(CodecConfig::Vvc {
config: vvc.config.clone(),
width,
height,
})
}
SampleEntryVariant::Av01(av1) => Ok(CodecConfig::Av1 {
config: av1.config.clone(),
width: av1.visual.width,
height: av1.visual.height,
}),
SampleEntryVariant::Vp09(vp9) => Ok(CodecConfig::Vp9 {
config: vp9.config.clone(),
width: vp9.visual.width,
height: vp9.visual.height,
}),
SampleEntryVariant::Mp4v(mp4v) => {
let esds = EsdsBox::parse_body(config_box_body(&mp4v.config_boxes, b"esds")?)?;
Ok(CodecConfig::Mpeg2Video {
esds,
width: mp4v.visual.width,
height: mp4v.visual.height,
})
}
SampleEntryVariant::Mp4a(mp4a) => {
let esds = esds_from_config_boxes(&mp4a.config_boxes)?;
let oti = esds
.es_descriptor
.decoder_config
.as_ref()
.map(|dc| dc.object_type_indication.0);
if oti == Some(OTI_MPEG2_AUDIO) || oti == Some(OTI_MPEG1_AUDIO) {
Ok(CodecConfig::MpegAudio {
esds,
layer: MpegAudioLayer::LayerII,
channel_count: mp4a.channelcount,
sample_rate: mp4a.samplerate >> 16,
sample_size: mp4a.samplesize,
})
} else {
Ok(CodecConfig::Aac {
esds,
channel_count: mp4a.channelcount,
sample_rate: mp4a.samplerate >> 16,
sample_size: mp4a.samplesize,
})
}
}
SampleEntryVariant::Ac3(ac3) => {
let config = Ac3SpecificBox::parse(config_box_body(&ac3.config_boxes, b"dac3")?)?;
Ok(CodecConfig::Ac3 {
config,
channel_count: ac3.channelcount,
sample_rate: ac3.samplerate >> 16,
sample_size: ac3.samplesize,
})
}
SampleEntryVariant::Ec3(ec3) => {
let config = Ec3SpecificBox::parse(config_box_body(&ec3.config_boxes, b"dec3")?)?;
Ok(CodecConfig::Eac3 {
config,
channel_count: ec3.channelcount,
sample_rate: ec3.samplerate >> 16,
sample_size: ec3.samplesize,
})
}
SampleEntryVariant::Opus(opus) => {
let config = OpusSpecificBox::parse(config_box_body(&opus.config_boxes, b"dOps")?)?;
Ok(CodecConfig::Opus {
config,
channel_count: opus.channelcount,
sample_rate: opus.samplerate >> 16,
sample_size: opus.samplesize,
})
}
SampleEntryVariant::Flac(flac) => {
let config = FlacSpecificBox::parse(config_box_body(&flac.config_boxes, b"dfLa")?)?;
Ok(CodecConfig::Flac {
config,
channel_count: flac.channelcount,
sample_rate: flac.samplerate >> 16,
sample_size: flac.samplesize,
})
}
SampleEntryVariant::Dts(dts) => {
let config = DtsSpecificBox::parse(config_box_body(&dts.config_boxes, b"ddts")?)?;
Ok(CodecConfig::Dts {
config,
codec_fourcc: dts.codec_type,
channel_count: dts.channelcount,
sample_rate: dts.samplerate >> 16,
sample_size: dts.samplesize,
})
}
SampleEntryVariant::Mha(mha) => {
let config = MHADecoderConfigurationRecord::parse(config_box_body(
&mha.config_boxes,
&MHAC_FOURCC,
)?)?;
Ok(CodecConfig::MpegH {
config,
channel_count: mha.channelcount,
sample_rate: mha.samplerate >> 16,
sample_size: mha.samplesize,
})
}
SampleEntryVariant::Ac4(ac4) => {
let config = Ac4SpecificBox::parse(config_box_body(
&ac4.config_boxes,
&crate::ac4::DAC4_FOURCC,
)?)?;
Ok(CodecConfig::Ac4 {
config,
channel_count: ac4.channelcount,
sample_rate: ac4.samplerate >> 16,
sample_size: ac4.samplesize,
})
}
SampleEntryVariant::Stpp(_) => Ok(CodecConfig::Subtitle {
format: SubtitleFormat::Ttml,
}),
SampleEntryVariant::Wvtt(_) => Ok(CodecConfig::Subtitle {
format: SubtitleFormat::WebVtt,
}),
SampleEntryVariant::Unknown(entry) => Err(Error::UnsupportedSampleEntry {
fourcc: String::from_utf8_lossy(&entry.box_type).into_owned(),
}),
}
}
pub(crate) fn refine_legacy_config(config: &mut CodecConfig, samples: &[Sample]) {
let Some(first) = samples.first() else {
return;
};
match config {
CodecConfig::Mpeg2Video { width, height, .. } => {
if let Ok(sh) = Mpeg2SeqHeader::find(&first.data) {
*width = sh.width;
*height = sh.height;
}
}
CodecConfig::MpegAudio { layer, .. } => {
if let Ok(hdr) = MpegAudioFrameHeader::parse(&first.data) {
*layer = hdr.layer;
}
}
_ => {}
}
}
fn esds_from_config_boxes(boxes: &[OpaqueBox]) -> Result<EsdsBox> {
EsdsBox::parse_body(config_box_body(boxes, b"esds")?)
}
fn config_box_body<'b>(boxes: &'b [OpaqueBox], fourcc: &[u8; 4]) -> Result<&'b [u8]> {
boxes
.iter()
.find(|b| &b.box_type == fourcc)
.map(|b| b.data.as_slice())
.ok_or(Error::UnexpectedBox {
expected: "config box in audio sample entry",
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subtitle_entries::WvttSampleEntry;
#[test]
fn wvtt_sample_entry_demuxes_to_subtitle_webvtt() {
let wvtt = WvttSampleEntry::new("WEBVTT\n");
let entry = SampleEntryVariant::Wvtt(alloc::boxed::Box::new(wvtt));
let config = codec_config_from_entry(&entry).expect("wvtt must now demux");
assert!(
matches!(
config,
CodecConfig::Subtitle {
format: SubtitleFormat::WebVtt
}
),
"wvtt must map to CodecConfig::Subtitle{{format: SubtitleFormat::WebVtt}}, got {config:?}"
);
}
#[test]
fn unknown_sample_entry_errors_naming_the_fourcc() {
let unknown = OpaqueBox::new(*b"xyz9", vec![1, 2, 3]);
let entry = SampleEntryVariant::Unknown(unknown);
let err = codec_config_from_entry(&entry).unwrap_err();
match err {
Error::UnsupportedSampleEntry { fourcc } => assert_eq!(fourcc, "xyz9"),
other => panic!("expected UnsupportedSampleEntry, got {other:?}"),
}
}
fn minimal_avc_config() -> crate::avc_config::AVCConfigurationBox {
use crate::avc_config::{AVCConfigurationBox, AVCDecoderConfigurationRecord};
use crate::nalu_types::{AvcPps, AvcSps};
AVCConfigurationBox {
config: AVCDecoderConfigurationRecord {
configuration_version: 1,
profile_indication: 66,
profile_compatibility: 0,
level_indication: 0x1E,
length_size_minus_one: 3,
sps: vec![AvcSps(vec![0x67, 0x42, 0x00, 0x1E, 0xAB, 0x40])],
pps: vec![AvcPps(vec![0x68, 0xCE, 0x3C, 0x80])],
chroma_format: None,
bit_depth_luma_minus8: None,
bit_depth_chroma_minus8: None,
sps_ext: Vec::new(),
},
}
}
#[test]
fn gapped_fmp4_reseeds_absolute_dts_from_each_fragments_own_tfdt() {
const TIMESCALE: u32 = 90_000;
const FRAME_DUR: u32 = 3_000;
const FRAMES_PER_FRAGMENT: u32 = 4;
const GAP_TICKS: u64 = FRAME_DUR as u64;
let spec = TrackSpec::new(
1,
TIMESCALE,
CodecConfig::Avc {
config: minimal_avc_config(),
width: 16,
height: 16,
},
);
let init = build_init_segment(core::slice::from_ref(&spec), TIMESCALE)
.expect("init segment builds");
let frames = |base: i64| -> Vec<Sample> {
(0..FRAMES_PER_FRAGMENT)
.map(|i| {
let nal = [0x41u8, 0xAA, 0xBB];
let mut data = (nal.len() as u32).to_be_bytes().to_vec();
data.extend_from_slice(&nal);
let dts = base + i64::from(i) * i64::from(FRAME_DUR);
Sample::new(data, Some(dts), Some(dts), Some(FRAME_DUR), i == 0)
})
.collect()
};
let first_base = 0u64;
let contiguous_second_base = u64::from(FRAMES_PER_FRAGMENT * FRAME_DUR);
let gapped_second_base = contiguous_second_base + GAP_TICKS;
let f1 = frames(first_base as i64);
let f2 = frames(gapped_second_base as i64);
let seg1 = build_media_segment(1, &[FragmentTrackData::new(1, first_base, &f1)])
.expect("fragment 1 builds");
let seg2 = build_media_segment(2, &[FragmentTrackData::new(1, gapped_second_base, &f2)])
.expect("fragment 2 builds");
let mut file = init;
file.extend_from_slice(&seg1);
file.extend_from_slice(&seg2);
let media = Fmp4Demux::new()
.unpackage(&file)
.expect("demux gapped fMP4");
let track = &media.tracks[0];
assert_eq!(
track.samples.len(),
(FRAMES_PER_FRAGMENT * 2) as usize,
"both fragments' samples must be present"
);
assert_eq!(
track.start_decode_time, first_base,
"start_decode_time stays the FIRST fragment's anchor, not the last"
);
let first_post_gap = &track.samples[FRAMES_PER_FRAGMENT as usize];
assert_eq!(
first_post_gap.dts,
Some(gapped_second_base as i64),
"post-gap dts must come from the second fragment's tfdt \
({gapped_second_base}), not the pure duration sum \
({contiguous_second_base})"
);
for (i, s) in track.samples[FRAMES_PER_FRAGMENT as usize..]
.iter()
.enumerate()
{
let expected = gapped_second_base as i64 + (i as i64) * i64::from(FRAME_DUR);
assert_eq!(s.dts, Some(expected), "sample {i} after the gap");
}
}
#[test]
fn contiguous_fmp4_dts_is_unchanged_by_the_per_fragment_tfdt_reseed() {
const TIMESCALE: u32 = 90_000;
const FRAME_DUR: u32 = 3_000;
const FRAMES: u32 = 3;
let spec = TrackSpec::new(
7,
TIMESCALE,
CodecConfig::Avc {
config: minimal_avc_config(),
width: 16,
height: 16,
},
);
let init = build_init_segment(core::slice::from_ref(&spec), TIMESCALE).unwrap();
let frames = |base: i64| -> Vec<Sample> {
(0..FRAMES)
.map(|i| {
let nal = [0x41u8, 0xAA, 0xBB];
let mut data = (nal.len() as u32).to_be_bytes().to_vec();
data.extend_from_slice(&nal);
let dts = base + i64::from(i) * i64::from(FRAME_DUR);
Sample::new(data, Some(dts), Some(dts), Some(FRAME_DUR), i == 0)
})
.collect()
};
let second_base = u64::from(FRAMES * FRAME_DUR);
let f1 = frames(0);
let f2 = frames(second_base as i64);
let mut file = init;
file.extend_from_slice(
&build_media_segment(1, &[FragmentTrackData::new(7, 0, &f1)]).unwrap(),
);
file.extend_from_slice(
&build_media_segment(2, &[FragmentTrackData::new(7, second_base, &f2)]).unwrap(),
);
let media = Fmp4Demux::new().unpackage(&file).unwrap();
let got: Vec<i64> = media.tracks[0]
.samples
.iter()
.filter_map(|s| s.dts)
.collect();
let want: Vec<i64> = (0..FRAMES * 2)
.map(|i| i64::from(i) * i64::from(FRAME_DUR))
.collect();
assert_eq!(got, want, "a gapless stream must be unaffected by the fix");
}
#[test]
fn hls_packager_omits_a_timestampless_track_instead_of_emitting_extinf_zero() {
const TIMESCALE: u32 = 90_000;
const FRAME_DUR: u32 = 3_000;
let timed = Track::new(
TrackSpec::new(
1,
TIMESCALE,
CodecConfig::Avc {
config: minimal_avc_config(),
width: 16,
height: 16,
},
),
(0..3)
.map(|i| {
Sample::new(
vec![0u8; 4],
Some(i64::from(i) * i64::from(FRAME_DUR)),
Some(i64::from(i) * i64::from(FRAME_DUR)),
Some(FRAME_DUR),
true,
)
})
.collect::<Vec<_>>(),
);
let sections = Track::new(
TrackSpec::new(
2,
TIMESCALE,
CodecConfig::Data {
stream_type: 0x86,
descriptors: Vec::new(),
carriage: crate::ir::DataCarriage::Sections,
},
),
vec![Sample::new(vec![0xFCu8; 8], None, None, None, true)],
);
let playlist = HlsPackager::default()
.package(&Media::new(vec![timed, sections], TIMESCALE))
.expect("a Media with one timed track still packages");
let zero_extinf = playlist.lines().any(|l| {
l.strip_prefix("#EXTINF:")
.and_then(|rest| rest.trim_end_matches(',').parse::<f64>().ok())
.is_some_and(|secs| secs == 0.0)
});
assert!(
!zero_extinf,
"no zero-duration EXTINF may be emitted, got:\n{playlist}"
);
assert!(
!playlist.contains("seg2.m4s"),
"the timestamp-less track must be omitted entirely, got:\n{playlist}"
);
assert!(
playlist.contains("seg1.m4s"),
"the timed track must still be present, got:\n{playlist}"
);
let only_sections = Track::new(
TrackSpec::new(
2,
TIMESCALE,
CodecConfig::Data {
stream_type: 0x86,
descriptors: Vec::new(),
carriage: crate::ir::DataCarriage::Sections,
},
),
vec![Sample::new(vec![0xFCu8; 8], None, None, None, true)],
);
assert!(
HlsPackager::default()
.package(&Media::new(vec![only_sections], TIMESCALE))
.is_err(),
"an all-timestamp-less Media must error, not render an empty playlist"
);
}
}