mod export;
pub mod fragment;
mod fragmenter;
mod import;
mod muxer;
pub use export::*;
pub use fragmenter::*;
pub use import::*;
pub use muxer::*;
#[cfg(test)]
mod export_test;
#[cfg(test)]
mod import_test;
use std::{task::Poll, time::Duration};
use bytes::Bytes;
use hang::catalog::{AudioCodec, AudioConfig, VideoCodec, VideoConfig};
use mp4_atom::Atom;
use moq_net::Timestamp;
use crate::container::{Container, Frame};
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("mp4: {0}")]
Mp4(std::sync::Arc<mp4_atom::Error>),
#[error("moq: {0}")]
Moq(#[from] moq_net::Error),
#[error("flac: {0}")]
Flac(#[from] crate::codec::flac::Error),
#[error("opus: {0}")]
Opus(#[from] crate::codec::opus::Error),
#[error("missing keyframe: a group must open on a keyframe")]
MissingKeyframe(#[from] crate::container::MissingKeyframe),
#[error("timestamp overflow")]
TimestampOverflow(#[from] moq_net::TimeOverflow),
#[error("no traf in moof")]
NoTraf,
#[error("no tfdt in traf")]
NoTfdt,
#[error("PTS overflow")]
PtsOverflow,
#[error("missing moof")]
NoMoof,
#[error("missing mdat")]
NoMdat,
#[error("missing moov")]
NoMoov,
#[error("no tracks in moov")]
NoTracks,
#[error("multiple tracks in moov, use Trak instead")]
MultipleTracks,
#[error("can't synthesize CMAF init for {0}")]
UnsupportedSynthesis(String),
#[error("subtitle tracks are not supported")]
UnsupportedSubtitle,
#[error("unknown track handler: {0:?}")]
UnknownTrackHandler([u8; 4]),
#[error("missing codec")]
MissingCodec,
#[error("multiple codecs")]
MultipleCodecs,
#[error("unknown codec: {0:?}")]
UnknownCodec(mp4_atom::FourCC),
#[error("unsupported codec: {0:?}")]
UnsupportedCodec(Box<mp4_atom::Codec>),
#[error("unsupported codec: MPEG2")]
UnsupportedMpeg2,
#[error("AAC sample entry missing DecoderSpecificInfo")]
MissingDecoderSpecific,
#[error("duplicate moof")]
DuplicateMoof,
#[error("missing trun")]
MissingTrun,
#[error("missing tfdt")]
MissingTfdt,
#[error("video codec {0} needs a description (codec config record) to synthesize a CMAF init")]
MissingVideoDescription(String),
#[error("video track {0} missing in catalog")]
MissingVideoTrack(String),
#[error("missing video dimensions for codec: {0}")]
MissingVideoDimensions(String),
#[error("audio track {0} missing in catalog")]
MissingAudioTrack(String),
#[error("invalid data offset")]
InvalidDataOffset,
#[error("unknown track {0}")]
UnknownTrack(u32),
#[error("no keyframe at start of group")]
NoKeyframe,
#[error("track sample range {start}..{end} is out of bounds of mdat (len {len})")]
SampleRangeOutOfBounds { start: usize, end: usize, len: usize },
#[error("no catalog snapshot")]
NoCatalogSnapshot,
#[error("encode_fragment called with no frames")]
NoFrames,
#[error("audio codec {0} needs a description (AudioSpecificConfig) to synthesize a CMAF init")]
MissingAudioDescription(String),
#[error("multi-sample fragment has a non-final sample with no duration; DTS is unrecoverable")]
MissingSampleDuration,
#[error("timescale {0} does not fit the 32-bit mdhd field")]
TimescaleTooLarge(u64),
#[error("a CMAF rendition's timescale comes from its init segment and can't be overridden")]
TimescaleOverride,
#[error("sample duration {0} does not fit the 32-bit trun field")]
SampleDurationTooLarge(u64),
#[error("sample duration is shorter than one tick at timescale {0}")]
SampleDurationTooSmall(u64),
#[error("sample duration is not exactly representable at timescale {0}")]
SampleDurationInexact(u64),
#[error("duration-less video needs stated durations or presentation-ordered inference")]
MissingVideoDuration,
}
impl From<mp4_atom::Error> for Error {
fn from(err: mp4_atom::Error) -> Self {
Error::Mp4(std::sync::Arc::new(err))
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct Wire {
trak: Box<mp4_atom::Trak>,
}
impl Wire {
pub fn new(trak: mp4_atom::Trak) -> Self {
Self { trak: Box::new(trak) }
}
pub fn from_init(init_data: &[u8]) -> Result<Self> {
use mp4_atom::DecodeMaybe;
let mut cursor = std::io::Cursor::new(init_data);
while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
if let mp4_atom::Any::Moov(mut moov) = atom {
return match moov.trak.len() {
1 => Ok(Self::new(moov.trak.remove(0))),
0 => Err(Error::NoTracks),
_ => Err(Error::MultipleTracks),
};
}
}
Err(Error::NoMoov)
}
pub fn trak(&self) -> &mp4_atom::Trak {
&self.trak
}
}
impl Container for Wire {
type Error = Error;
fn write(&self, group: &mut moq_net::group::Producer, frames: &[Frame]) -> std::result::Result<(), Self::Error> {
let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
let track_id = self.trak.tkhd.track_id;
encode(group, frames, timescale, track_id)
}
fn poll_read(
&self,
group: &mut moq_net::group::Consumer,
waiter: &kio::Waiter,
) -> Poll<std::result::Result<Option<Vec<Frame>>, Self::Error>> {
use std::task::ready;
let Some(frame) = ready!(group.poll_read_frame(waiter)?) else {
return Poll::Ready(Ok(None));
};
let timescale = moq_net::Timescale::new(self.trak.mdia.mdhd.timescale as u64)?;
Poll::Ready(Ok(Some(decode(frame.payload, timescale)?)))
}
}
pub(crate) fn decode(data: Bytes, timescale: moq_net::Timescale) -> Result<Vec<Frame>> {
use mp4_atom::DecodeMaybe;
let mut cursor = std::io::Cursor::new(&data);
let mut moof = None;
let mut mdat_data = None;
while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor)? {
match atom {
mp4_atom::Any::Moof(m) => moof = Some(m),
mp4_atom::Any::Mdat(m) => mdat_data = Some(m.data),
_ => {}
}
}
let moof = moof.ok_or(Error::NoMoof)?;
let mdat_data = mdat_data.ok_or(Error::NoMdat)?;
let traf = moof.traf.first().ok_or(Error::NoTraf)?;
let tfdt = traf.tfdt.as_ref().ok_or(Error::NoTfdt)?;
let base_dts = tfdt.base_media_decode_time;
let default_size = traf.tfhd.default_sample_size;
let default_duration = traf.tfhd.default_sample_duration;
let total_samples: usize = traf.trun.iter().map(|t| t.entries.len()).sum();
let mut frames = Vec::new();
let mut offset = 0usize;
let mut dts = base_dts;
let mut sample_index = 0usize;
for trun in &traf.trun {
for entry in &trun.entries {
let size = entry.size.or(default_size).unwrap_or(0) as usize;
let end = offset + size;
if end > mdat_data.len() {
return Err(Error::SampleRangeOutOfBounds {
start: offset,
end,
len: mdat_data.len(),
});
}
let cts = entry.cts.unwrap_or_default() as i64;
let pts = dts.checked_add_signed(cts).ok_or(Error::PtsOverflow)?;
let timestamp = Timestamp::new(pts, timescale)?;
let payload = Bytes::copy_from_slice(&mdat_data[offset..end]);
let flags = entry.flags.unwrap_or(0);
let keyframe = (flags >> 24) & 0x3 == 0x2;
let sample_duration = entry.duration.or(default_duration).filter(|d| *d != 0);
let is_last = sample_index + 1 == total_samples;
if sample_duration.is_none() && !is_last {
return Err(Error::MissingSampleDuration);
}
let duration = sample_duration
.map(|d| Timestamp::new(d as u64, timescale))
.transpose()?;
frames.push(Frame {
timestamp,
payload,
keyframe,
duration,
});
offset = end;
dts += sample_duration.unwrap_or(0) as u64;
sample_index += 1;
}
}
Ok(frames)
}
pub(crate) fn encode(
group: &mut moq_net::group::Producer,
frames: &[Frame],
timescale: moq_net::Timescale,
track_id: u32,
) -> Result<()> {
if frames.is_empty() {
return Ok(());
}
let sequence_number = group.frame_count() as u32;
let info = FragmentInfo {
track_id,
timescale,
sequence_number,
};
let bytes = encode_fragment(info, frames)?;
let mut writer = group.create_frame(moq_net::frame::Info {
size: bytes.len() as u64,
timestamp: frames[0].timestamp,
})?;
writer.write(bytes)?;
writer.finish()?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct FragmentInfo {
pub track_id: u32,
pub timescale: moq_net::Timescale,
pub sequence_number: u32,
}
pub(crate) fn encode_fragment(info: FragmentInfo, frames: &[Frame]) -> Result<Bytes> {
let Some(first) = frames.first() else {
return Ok(Bytes::new());
};
encode_at(info, base_ticks(first, info.timescale)?, frames)
}
fn base_ticks(frame: &Frame, timescale: moq_net::Timescale) -> Result<u64> {
timestamp_ticks(frame.timestamp, timescale)
}
fn timestamp_ticks(timestamp: Timestamp, timescale: moq_net::Timescale) -> Result<u64> {
let source_scale = u128::from(timestamp.scale().as_u64());
let scaled = u128::from(timestamp.value()) * u128::from(timescale.as_u64());
let ticks = (scaled + source_scale / 2) / source_scale;
u64::try_from(ticks).map_err(|_| Error::PtsOverflow)
}
fn encode_at(info: FragmentInfo, base_dts: u64, frames: &[Frame]) -> Result<Bytes> {
let FragmentInfo {
track_id,
timescale,
sequence_number,
} = info;
use mp4_atom::Encode;
if frames.is_empty() {
return Ok(Bytes::new());
}
let mut dts = base_dts;
let entries: Vec<_> = frames
.iter()
.map(|f| {
let flags = if f.keyframe { 0x0200_0000 } else { 0x0001_0000 };
let duration = f.duration.map(|d| trun_duration(d, timescale)).transpose()?;
let pts = i128::from(timestamp_ticks(f.timestamp, timescale)?);
let cts = pts - i128::from(dts);
let cts = i32::try_from(cts).map_err(|_| Error::PtsOverflow)?;
if let Some(duration) = duration {
dts = dts.checked_add(u64::from(duration)).ok_or(Error::PtsOverflow)?;
}
Ok(mp4_atom::TrunEntry {
duration,
size: Some(f.payload.len() as u32),
flags: Some(flags),
cts: (cts != 0).then_some(cts),
})
})
.collect::<Result<_>>()?;
let mdat_data: Vec<u8> = frames.iter().flat_map(|f| f.payload.iter().copied()).collect();
let build_moof = |data_offset| mp4_atom::Moof {
mfhd: mp4_atom::Mfhd { sequence_number },
traf: vec![mp4_atom::Traf {
tfhd: mp4_atom::Tfhd {
track_id,
..Default::default()
},
tfdt: Some(mp4_atom::Tfdt {
base_media_decode_time: base_dts,
}),
trun: vec![mp4_atom::Trun {
data_offset: Some(data_offset),
entries: entries.clone(),
}],
..Default::default()
}],
};
let mut buf = Vec::new();
build_moof(0).encode(&mut buf)?;
let moof_size = buf.len();
buf.clear();
build_moof((moof_size + 8) as i32).encode(&mut buf)?;
let mdat = mp4_atom::Mdat { data: mdat_data };
mdat.encode(&mut buf)?;
Ok(Bytes::from(buf))
}
fn trun_duration(duration: Timestamp, timescale: moq_net::Timescale) -> Result<u32> {
let source_timescale = duration.scale().as_u64();
let output_timescale = timescale.as_u64();
let scaled = u128::from(duration.value()) * u128::from(output_timescale);
let ticks = scaled / u128::from(source_timescale);
if !duration.is_zero() && ticks == 0 {
return Err(Error::SampleDurationTooSmall(timescale.as_u64()));
}
if scaled % u128::from(source_timescale) != 0 {
return Err(Error::SampleDurationInexact(output_timescale));
}
u32::try_from(ticks).map_err(|_| Error::SampleDurationTooLarge(u64::try_from(ticks).unwrap_or(u64::MAX)))
}
pub(crate) fn synthesize_video_trak(
track_id: u32,
timescale: u64,
config: &VideoConfig,
description: Option<&[u8]>,
) -> Result<mp4_atom::Trak> {
if !matches!(
config.codec,
VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::AV1(_) | VideoCodec::VP8 | VideoCodec::VP9(_)
) {
return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", config.codec)));
}
let width = u16::try_from(
config
.coded_width
.ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?,
)
.map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?;
let height = u16::try_from(
config
.coded_height
.ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?,
)
.map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?;
if width == 0 || height == 0 {
return Err(Error::MissingVideoDimensions(config.codec.to_string()));
}
let visual = mp4_atom::Visual {
data_reference_index: 1,
width,
height,
..Default::default()
};
let require_description = || description.ok_or_else(|| Error::MissingVideoDescription(config.codec.to_string()));
let sample_entry = match &config.codec {
VideoCodec::H264(_) => {
let mut cursor = std::io::Cursor::new(require_description()?);
let avcc = mp4_atom::Avcc::decode_body(&mut cursor).map_err(Error::from)?;
mp4_atom::Codec::from(mp4_atom::Avc1 {
visual,
avcc,
..Default::default()
})
}
VideoCodec::H265(h265) => {
let mut cursor = std::io::Cursor::new(require_description()?);
let hvcc = mp4_atom::Hvcc::decode_body(&mut cursor).map_err(Error::from)?;
if h265.in_band {
mp4_atom::Codec::from(mp4_atom::Hev1 {
visual,
hvcc,
..Default::default()
})
} else {
mp4_atom::Codec::from(mp4_atom::Hvc1 {
visual,
hvcc,
..Default::default()
})
}
}
VideoCodec::AV1(av1) => mp4_atom::Codec::from(mp4_atom::Av01 {
visual,
av1c: crate::codec::av1::av1c_from_av1(av1),
..Default::default()
}),
VideoCodec::VP8 => mp4_atom::Codec::from(mp4_atom::Vp08 {
visual,
vpcc: crate::codec::vp8::vpcc(),
..Default::default()
}),
VideoCodec::VP9(vp9) => mp4_atom::Codec::from(mp4_atom::Vp09 {
visual,
vpcc: crate::codec::vp9::vpcc(vp9),
..Default::default()
}),
other => unreachable!("unsupported codecs rejected before geometry synthesis: {other:?}"),
};
Ok(build_video_trak(
track_id,
mdhd_timescale(timescale)?,
sample_entry,
width,
height,
))
}
pub(crate) fn synthesize_audio_trak(track_id: u32, timescale: u64, config: &AudioConfig) -> Result<mp4_atom::Trak> {
use mp4_atom::Decode;
let audio = mp4_atom::Audio {
data_reference_index: 1,
channel_count: config.channel_count as u16,
sample_size: 16,
sample_rate: mp4_atom::FixedPoint::from(config.sample_rate as u16),
};
let sample_entry = match &config.codec {
AudioCodec::Opus => {
let pre_skip = match &config.description {
Some(description) => {
let mut description = description.as_ref();
crate::codec::opus::Config::parse(&mut description)?.pre_skip
}
None => 0,
};
mp4_atom::Codec::from(mp4_atom::Opus {
audio,
dops: mp4_atom::Dops {
output_channel_count: config.channel_count as u8,
pre_skip,
input_sample_rate: config.sample_rate,
output_gain: 0,
},
btrt: None,
})
}
AudioCodec::AAC(_) => {
let description = config
.description
.as_ref()
.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
let mut cursor = std::io::Cursor::new(description.as_ref());
let dec_specific = mp4_atom::esds::DecoderSpecific::decode(&mut cursor)?;
let bitrate = config.bitrate.and_then(|b| u32::try_from(b).ok()).filter(|b| *b > 0);
let (max_bitrate, avg_bitrate) = match bitrate {
Some(bitrate) => (bitrate, bitrate),
None => (256_000, 128_000),
};
mp4_atom::Codec::from(mp4_atom::Mp4a {
audio,
esds: mp4_atom::Esds {
es_desc: mp4_atom::esds::EsDescriptor {
es_id: 0,
dec_config: mp4_atom::esds::DecoderConfig {
object_type_indication: 0x40, stream_type: 0x05, up_stream: 0,
buffer_size_db: mp4_atom::u24::from([0x00, 0x60, 0x00]),
max_bitrate,
avg_bitrate,
dec_specific: Some(dec_specific),
},
sl_config: Default::default(),
},
},
btrt: None,
taic: None,
})
}
AudioCodec::Flac => {
let description = config
.description
.as_ref()
.ok_or_else(|| Error::MissingAudioDescription(config.codec.to_string()))?;
let info = crate::codec::flac::Config::parse(&mut description.as_ref())?;
let stream_info = mp4_atom::FlacMetadataBlock::StreamInfo {
minimum_block_size: info.min_block_size,
maximum_block_size: info.max_block_size,
minimum_frame_size: info.min_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
maximum_frame_size: info.max_frame_size.min(0xFF_FFFF).try_into().expect("fits in u24"),
sample_rate: info.sample_rate,
num_channels_minus_one: info.channel_count.saturating_sub(1) as u8,
bits_per_sample_minus_one: info.bits_per_sample.saturating_sub(1) as u8,
number_of_interchannel_samples: info.total_samples,
md5_checksum: info.md5.to_vec(),
};
mp4_atom::Codec::from(mp4_atom::Flac {
audio,
dfla: mp4_atom::Dfla {
blocks: vec![stream_info],
},
})
}
other => return Err(Error::UnsupportedSynthesis(format!("audio codec {:?}", other))),
};
Ok(build_audio_trak(track_id, mdhd_timescale(timescale)?, sample_entry))
}
const UNKNOWN_DURATION: u64 = u64::MAX;
fn build_video_trak(
track_id: u32,
timescale: u32,
sample_entry: mp4_atom::Codec,
width: u16,
height: u16,
) -> mp4_atom::Trak {
mp4_atom::Trak {
tkhd: mp4_atom::Tkhd {
track_id,
enabled: true,
in_movie: true,
duration: UNKNOWN_DURATION,
width: mp4_atom::FixedPoint::from(width),
height: mp4_atom::FixedPoint::from(height),
..Default::default()
},
mdia: build_mdia(timescale, b"vide", true, sample_entry),
..Default::default()
}
}
fn build_audio_trak(track_id: u32, timescale: u32, sample_entry: mp4_atom::Codec) -> mp4_atom::Trak {
mp4_atom::Trak {
tkhd: mp4_atom::Tkhd {
track_id,
enabled: true,
in_movie: true,
duration: UNKNOWN_DURATION,
volume: mp4_atom::FixedPoint::from(1),
..Default::default()
},
mdia: build_mdia(timescale, b"soun", false, sample_entry),
..Default::default()
}
}
pub(crate) fn encode_init(
ftyp: Option<mp4_atom::Ftyp>,
traks: Vec<mp4_atom::Trak>,
trexs: Vec<mp4_atom::Trex>,
) -> Result<Bytes> {
use mp4_atom::Encode;
let ftyp = ftyp.unwrap_or(mp4_atom::Ftyp {
major_brand: b"isom".into(),
minor_version: 0x200,
compatible_brands: vec![b"isom".into(), b"iso6".into(), b"mp41".into()],
});
let timescale = traks.first().map(|t| t.mdia.mdhd.timescale).unwrap_or(1000);
let next_track_id = traks.iter().map(|t| t.tkhd.track_id).max().unwrap_or(0) + 1;
let moov = mp4_atom::Moov {
mvhd: mp4_atom::Mvhd {
timescale,
duration: UNKNOWN_DURATION,
rate: mp4_atom::FixedPoint::from(1),
volume: mp4_atom::FixedPoint::from(1),
next_track_id,
..Default::default()
},
trak: traks,
mvex: (!trexs.is_empty()).then(|| mp4_atom::Mvex {
trex: trexs,
..Default::default()
}),
..Default::default()
};
let mut buf = Vec::new();
ftyp.encode(&mut buf)?;
moov.encode(&mut buf)?;
Ok(Bytes::from(buf))
}
fn mdhd_timescale(timescale: u64) -> Result<u32> {
u32::try_from(timescale).map_err(|_| Error::TimescaleTooLarge(timescale))
}
fn build_mdia(timescale: u32, handler: &[u8; 4], is_video: bool, sample_entry: mp4_atom::Codec) -> mp4_atom::Mdia {
mp4_atom::Mdia {
mdhd: mp4_atom::Mdhd {
timescale,
..Default::default()
},
hdlr: mp4_atom::Hdlr {
handler: mp4_atom::FourCC::new(handler),
name: String::new(),
},
minf: mp4_atom::Minf {
vmhd: is_video.then(mp4_atom::Vmhd::default),
smhd: (!is_video).then(mp4_atom::Smhd::default),
dinf: mp4_atom::Dinf {
dref: mp4_atom::Dref {
urls: vec![mp4_atom::Url::default()],
},
},
stbl: mp4_atom::Stbl {
stsd: mp4_atom::Stsd {
codecs: vec![sample_entry],
},
..Default::default()
},
..Default::default()
},
}
}
pub(crate) fn default_video_timescale(config: &VideoConfig) -> u64 {
usable_video_framerate(config)
.and_then(select_video_timescale)
.unwrap_or(90_000)
}
pub(crate) fn usable_video_framerate(config: &VideoConfig) -> Option<f64> {
config
.framerate
.filter(|fps| fps.is_finite() && *fps > 0.0 && (*fps * 1000.0) as u64 > 0)
.filter(|fps| select_video_timescale(*fps).is_some())
}
fn select_video_timescale(framerate: f64) -> Option<u64> {
let preferred = (framerate * 1000.0) as u64;
let frame = Duration::from_secs_f64(1.0 / framerate);
for timescale in [preferred, (framerate * 1001.0).round() as u64] {
if duration_fits_trun(frame, timescale) {
return Some(timescale);
}
}
const NANOS_PER_SECOND: u128 = 1_000_000_000;
let exact = NANOS_PER_SECOND / gcd(frame.as_nanos(), NANOS_PER_SECOND);
let exact = u64::try_from(exact).ok()?;
if duration_fits_trun(frame, exact) {
return Some(exact);
}
if let Some(timescale) = rational_timescale(frame) {
return Some(timescale);
}
let max_scale = u128::from(u32::MAX)
.checked_mul(NANOS_PER_SECOND)?
.checked_div(frame.as_nanos())?
.min(u128::from(u32::MAX));
let max_scale = u64::try_from(max_scale).ok()?;
duration_fits_trun(frame, max_scale).then_some(max_scale)
}
fn rational_timescale(duration: Duration) -> Option<u64> {
const NANOS_PER_SECOND: u128 = 1_000_000_000;
let mut numerator = duration.as_nanos();
let mut denominator = NANOS_PER_SECOND;
let (mut previous_ticks, mut ticks) = (0_u128, 1_u128);
let (mut previous_scale, mut scale) = (1_u128, 0_u128);
while denominator != 0 {
let coefficient = numerator / denominator;
let next_ticks = coefficient.checked_mul(ticks)?.checked_add(previous_ticks)?;
let next_scale = coefficient.checked_mul(scale)?.checked_add(previous_scale)?;
if next_ticks > u128::from(u32::MAX) || next_scale > u128::from(u32::MAX) {
break;
}
let candidate = u64::try_from(next_scale).ok()?;
if duration_fits_trun(duration, candidate) {
return Some(candidate);
}
(previous_ticks, ticks) = (ticks, next_ticks);
(previous_scale, scale) = (scale, next_scale);
(numerator, denominator) = (denominator, numerator % denominator);
}
None
}
fn duration_fits_trun(duration: Duration, timescale: u64) -> bool {
timescale > 0 && rounded_duration_ticks(duration, timescale).is_some_and(|ticks| u32::try_from(ticks).is_ok())
}
fn rounded_duration_ticks(duration: Duration, timescale: u64) -> Option<u64> {
const NANOS_PER_SECOND: u128 = 1_000_000_000;
let scaled = duration.as_nanos().checked_mul(u128::from(timescale))?;
let rounded = scaled.checked_add(NANOS_PER_SECOND / 2)? / NANOS_PER_SECOND;
let exact = rounded.checked_mul(NANOS_PER_SECOND)?;
if scaled.abs_diff(exact) > u128::from(timescale) {
return None;
}
u64::try_from(rounded).ok()
}
fn gcd(mut a: u128, mut b: u128) -> u128 {
while b != 0 {
(a, b) = (b, a % b);
}
a
}
#[cfg(test)]
pub(crate) fn timeline(fragment: &Bytes) -> (u64, Vec<i32>) {
let traf = first_traf(fragment);
let cts = traf.trun[0].entries.iter().map(|e| e.cts.unwrap_or_default()).collect();
(traf.tfdt.as_ref().unwrap().base_media_decode_time, cts)
}
#[cfg(test)]
pub(crate) fn sample_durations(fragment: &Bytes) -> Vec<Option<u32>> {
first_traf(fragment).trun[0]
.entries
.iter()
.map(|e| e.duration)
.collect()
}
#[cfg(test)]
fn first_traf(fragment: &Bytes) -> mp4_atom::Traf {
use mp4_atom::DecodeMaybe;
let mut cursor = std::io::Cursor::new(fragment.as_ref());
while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
if let mp4_atom::Any::Moof(moof) = atom {
return moof.traf.into_iter().next().expect("a traf");
}
}
panic!("no moof");
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(micros: u64) -> Timestamp {
Timestamp::from_micros(micros).unwrap()
}
fn info(track_id: u32, timescale: moq_net::Timescale, sequence_number: u32) -> FragmentInfo {
FragmentInfo {
track_id,
timescale,
sequence_number,
}
}
fn aac_config(bitrate: Option<u64>) -> AudioConfig {
let mut config = AudioConfig::new(AudioCodec::AAC(hang::catalog::AAC { profile: 2 }), 44_100, 2);
config.description = Some(Bytes::from_static(&[0x12, 0x10]));
config.bitrate = bitrate;
config
}
fn moov(init: &Bytes) -> mp4_atom::Moov {
use mp4_atom::DecodeMaybe;
let mut cursor = std::io::Cursor::new(init.as_ref());
while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).unwrap() {
if let mp4_atom::Any::Moov(moov) = atom {
return moov;
}
}
panic!("no moov");
}
fn dec_config(trak: &mp4_atom::Trak) -> mp4_atom::esds::DecoderConfig {
match &trak.mdia.minf.stbl.stsd.codecs[0] {
mp4_atom::Codec::Mp4a(mp4a) => mp4a.esds.es_desc.dec_config.clone(),
other => panic!("expected mp4a, got {other:?}"),
}
}
#[test]
fn synthesized_aac_init_has_non_zero_bitrates() {
let inferred = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap());
assert_ne!(u32::from(inferred.buffer_size_db), 0);
assert_ne!(inferred.max_bitrate, 0);
assert_ne!(inferred.avg_bitrate, 0);
let stated = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(Some(96_000))).unwrap());
assert_eq!(stated.max_bitrate, 96_000, "the catalog's bitrate wins");
assert_eq!(stated.avg_bitrate, 96_000);
for unusable in [Some(0), Some(u64::from(u32::MAX) + 1)] {
let config = dec_config(&synthesize_audio_trak(1, 44_100, &aac_config(unusable)).unwrap());
assert_eq!(config.max_bitrate, inferred.max_bitrate, "{unusable:?}");
assert_eq!(config.avg_bitrate, inferred.avg_bitrate, "{unusable:?}");
}
}
#[test]
fn default_video_timescale_ignores_an_unusable_framerate() {
let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
for unusable in [0.0, -30.0, f64::NAN, f64::INFINITY, 0.0005] {
config.framerate = Some(unusable);
assert_eq!(default_video_timescale(&config), 90_000, "{unusable}");
}
config.framerate = Some(30.0);
assert_eq!(default_video_timescale(&config), 30_000);
config.framerate = Some(30_000.0 / 1001.0);
assert_eq!(default_video_timescale(&config), 30_000);
}
#[test]
fn default_video_timescale_keeps_low_cadence_within_trun() {
let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
for framerate in [0.2001, 0.0011] {
config.framerate = Some(framerate);
let timescale = default_video_timescale(&config);
let duration = Duration::from_secs_f64(1.0 / framerate);
let ticks = rounded_duration_ticks(duration, timescale).unwrap();
assert!(timescale <= u64::from(u32::MAX));
assert!(ticks <= u64::from(u32::MAX));
}
assert_eq!(default_video_timescale(&config), 11);
}
#[test]
fn synthesized_init_declares_unknown_duration() {
let trak = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
assert_eq!(trak.tkhd.duration, u64::MAX);
let init = encode_init(None, vec![trak], Vec::new()).unwrap();
let moov = moov(&init);
assert_eq!(moov.mvhd.duration, u64::MAX);
assert_eq!(moov.trak[0].tkhd.duration, u64::MAX);
}
#[test]
fn synthesized_init_headers_describe_a_playable_presentation() {
let audio = synthesize_audio_trak(1, 44_100, &aac_config(None)).unwrap();
let init = encode_init(None, vec![audio], Vec::new()).unwrap();
let moov = moov(&init);
assert_eq!(moov.mvhd.rate.integer(), 1, "normal playback rate");
assert_eq!(moov.mvhd.volume.integer(), 1, "full volume");
assert_eq!(moov.mvhd.next_track_id, 2, "past the only track id");
let tkhd = &moov.trak[0].tkhd;
assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
assert_eq!(tkhd.volume.integer(), 1, "an audio track carries the volume");
}
#[test]
fn synthesized_video_init_sets_the_track_flags() {
let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
config.coded_width = Some(320);
config.coded_height = Some(240);
config.framerate = Some(30.0);
let video = synthesize_video_trak(1, 30_000, &config, None).unwrap();
let init = encode_init(None, vec![video], Vec::new()).unwrap();
let moov = moov(&init);
let tkhd = &moov.trak[0].tkhd;
assert!(tkhd.enabled && tkhd.in_movie, "flags 0x000003");
assert_eq!(tkhd.volume.integer(), 0);
}
#[test]
fn synthesized_video_init_rejects_missing_dimensions() {
let config = VideoConfig::new(hang::catalog::VideoCodec::VP8);
let error = synthesize_video_trak(1, 30_000, &config, None).unwrap_err();
assert!(matches!(error, Error::MissingVideoDimensions(_)));
}
#[test]
fn decode_reads_trun_sample_duration() {
use mp4_atom::Encode;
let timescale = moq_net::Timescale::MICRO;
let moof = mp4_atom::Moof {
mfhd: mp4_atom::Mfhd { sequence_number: 0 },
traf: vec![mp4_atom::Traf {
tfhd: mp4_atom::Tfhd {
track_id: 1,
..Default::default()
},
tfdt: Some(mp4_atom::Tfdt {
base_media_decode_time: 0,
}),
trun: vec![mp4_atom::Trun {
data_offset: Some(0),
entries: vec![
mp4_atom::TrunEntry {
size: Some(2),
duration: Some(33_333),
..Default::default()
},
mp4_atom::TrunEntry {
size: Some(2),
duration: Some(33_333),
..Default::default()
},
],
}],
..Default::default()
}],
};
let mut buf = Vec::new();
moof.encode(&mut buf).unwrap();
mp4_atom::Mdat {
data: vec![0xDE, 0xAD, 0xBE, 0xEF],
}
.encode(&mut buf)
.unwrap();
let frames = decode(Bytes::from(buf), timescale).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].timestamp, ts(0));
assert_eq!(frames[0].duration, Some(ts(33_333)));
assert_eq!(frames[1].timestamp, ts(33_333));
assert_eq!(frames[1].duration, Some(ts(33_333)));
}
#[test]
fn duration_round_trips_through_encode() {
let timescale = moq_net::Timescale::MICRO;
let input = vec![Frame {
timestamp: ts(0),
payload: Bytes::from_static(&[0xDE, 0xAD]),
keyframe: true,
duration: Some(ts(33_333)),
}];
let fragment = encode_fragment(info(1, timescale, 0), &input).unwrap();
let frames = decode(fragment, timescale).unwrap();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].duration, Some(ts(33_333)));
}
#[test]
fn encode_fragment_rejects_a_duration_too_large_for_trun() {
let timescale = moq_net::Timescale::new(u64::from(u32::MAX)).unwrap();
let over = u64::from(u32::MAX) + 1;
let frame = Frame {
timestamp: Timestamp::from_scale(0, timescale.as_u64()).unwrap(),
payload: Bytes::from_static(&[0xDE, 0xAD]),
keyframe: true,
duration: Some(Timestamp::from_scale(over, timescale.as_u64()).unwrap()),
};
let err = encode_fragment(info(1, timescale, 0), std::slice::from_ref(&frame)).unwrap_err();
assert!(matches!(err, Error::SampleDurationTooLarge(ticks) if ticks == over));
let largest = Frame {
duration: Some(Timestamp::from_scale(u64::from(u32::MAX), timescale.as_u64()).unwrap()),
..frame
};
let fragment = encode_fragment(info(1, timescale, 0), &[largest]).unwrap();
assert_eq!(sample_durations(&fragment), vec![Some(u32::MAX)]);
}
#[test]
fn encode_fragment_rejects_a_duration_shorter_than_one_tick() {
let timescale = moq_net::Timescale::SECOND;
let frame = Frame {
timestamp: Timestamp::from_secs(0).unwrap(),
payload: Bytes::from_static(&[0xDE, 0xAD]),
keyframe: true,
duration: Some(Timestamp::from_millis(33).unwrap()),
};
let err = encode_fragment(info(1, timescale, 0), std::slice::from_ref(&frame)).unwrap_err();
assert!(matches!(err, Error::SampleDurationTooSmall(1)));
let one_tick = Frame {
duration: Some(Timestamp::from_secs(1).unwrap()),
..frame
};
let fragment = encode_fragment(info(1, timescale, 0), &[one_tick]).unwrap();
assert_eq!(sample_durations(&fragment), vec![Some(1)]);
}
#[test]
fn encode_fragment_rejects_an_inexact_sample_duration() {
let input_scale = moq_net::Timescale::new(24).unwrap();
let output_scale = moq_net::Timescale::MILLI;
let frame = Frame {
timestamp: Timestamp::new(0, input_scale).unwrap(),
payload: Bytes::from_static(&[0xDE, 0xAD]),
keyframe: true,
duration: Some(Timestamp::new(1, input_scale).unwrap()),
};
let err = encode_fragment(info(1, output_scale, 0), std::slice::from_ref(&frame)).unwrap_err();
assert!(matches!(err, Error::SampleDurationInexact(1_000)));
let exact_scale = moq_net::Timescale::new(24_000).unwrap();
let fragment = encode_fragment(info(1, exact_scale, 0), &[frame]).unwrap();
assert_eq!(sample_durations(&fragment), vec![Some(1_000)]);
}
#[test]
fn encode_fragment_rejects_a_pts_too_large_for_tfdt() {
let timescale = moq_net::Timescale::new(u64::from(u32::MAX)).unwrap();
let frame = Frame {
timestamp: Timestamp::from_secs(1 << 40).unwrap(),
payload: Bytes::from_static(&[0xDE, 0xAD]),
keyframe: true,
duration: None,
};
let err = encode_fragment(info(1, timescale, 0), &[frame]).unwrap_err();
assert!(matches!(err, Error::PtsOverflow));
}
#[test]
fn reordered_pts_round_trips_with_cts() {
let timescale = moq_net::Timescale::new(1_000_000).unwrap();
let input = vec![
Frame {
timestamp: ts(0),
payload: Bytes::from_static(&[0x00]),
keyframe: true,
duration: Some(ts(33_000)),
},
Frame {
timestamp: ts(99_000),
payload: Bytes::from_static(&[0x01]),
keyframe: false,
duration: Some(ts(33_000)),
},
Frame {
timestamp: ts(33_000),
payload: Bytes::from_static(&[0x02]),
keyframe: false,
duration: Some(ts(33_000)),
},
];
let fragment = encode_fragment(info(1, timescale, 0), &input).unwrap();
let frames = decode(fragment, timescale).unwrap();
assert_eq!(frames.len(), input.len());
for (actual, expected) in frames.iter().zip(&input) {
assert_eq!(actual.timestamp, expected.timestamp);
assert_eq!(actual.duration, expected.duration);
assert_eq!(actual.payload, expected.payload);
}
}
#[test]
fn decode_without_duration_reports_none() {
let timescale = moq_net::Timescale::new(90_000).unwrap();
let frames = vec![Frame {
timestamp: ts(0),
payload: Bytes::from_static(&[0xDE, 0xAD]),
keyframe: true,
duration: None,
}];
let fragment = encode_fragment(info(1, timescale, 0), &frames).unwrap();
let frames = decode(fragment, timescale).unwrap();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].duration, None);
}
#[test]
fn decode_zero_duration_reports_none() {
use mp4_atom::Encode;
let timescale = moq_net::Timescale::new(24_000).unwrap();
let moof = mp4_atom::Moof {
mfhd: mp4_atom::Mfhd { sequence_number: 0 },
traf: vec![mp4_atom::Traf {
tfhd: mp4_atom::Tfhd {
track_id: 1,
default_sample_duration: Some(0),
default_sample_size: Some(2),
..Default::default()
},
tfdt: Some(mp4_atom::Tfdt {
base_media_decode_time: 2_000,
}),
trun: vec![mp4_atom::Trun {
data_offset: Some(0),
entries: vec![mp4_atom::TrunEntry {
size: None,
duration: None,
..Default::default()
}],
}],
..Default::default()
}],
};
let mut buf = Vec::new();
moof.encode(&mut buf).unwrap();
mp4_atom::Mdat { data: vec![0xDE, 0xAD] }.encode(&mut buf).unwrap();
let frames = decode(Bytes::from(buf), timescale).unwrap();
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].timestamp.as_micros(), 83_333);
assert_eq!(frames[0].duration, None);
}
}